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}
222
223/// `SpawnSessionOptions` -> g4a selection. Effort needs a model (g4a options hang off a model).
224fn spawn_selection(
225    options: &gate4agent_node_protocol::SpawnSessionOptions,
226) -> Result<Option<gate4agent_types::SessionOptionSelection>, NodeFailure> {
227    options.validate().map_err(|e| failure(NodeFailureCode::InvalidRequest, e))?;
228    match (&options.model, &options.effort) {
229        (None, None) => Ok(None),
230        (None, Some(_)) => Err(failure(NodeFailureCode::InvalidRequest, "spawn effort needs a model")),
231        (Some(model), effort) => {
232            let mut sel = gate4agent_types::SessionOptionSelection::new(model.clone());
233            if let Some(e) = effort {
234                sel = sel.with_value("effort", e.clone());
235            }
236            Ok(Some(sel))
237        }
238    }
239}
240
241mod browser_bridge;
242
243use gate4agent_node_core::drive_loop_profile;
244
245#[path = "bundle_delivery.rs"]
246mod bundle_delivery;
247
248#[path = "context_pack_store.rs"]
249mod context_pack_store;
250
251#[cfg(windows)]
252pub use crate::platform::DEFAULT_NODE_ENDPOINT;
253
254const NODE_EVENT_HISTORY_MAX: usize = 4_096;
255const NODE_BROADCAST_CAPACITY: usize = 1_024;
256const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
257/// Unlike `NODE_TERMINAL_BROADCAST_CAPACITY`'s single replaceable slot (a
258/// terminal frame is a full-screen snapshot; only the latest ever matters),
259/// an `AgentStreamChunkV1` is one delta in an ordered sequence -- collapsing
260/// two pending sends into one would silently drop content a subscriber can
261/// never recover. This needs headroom for a burst instead.
262const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
263/// The node never has more than `MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE`
264/// `NodeEvent::HarnessMcpReadCall`s in flight at once (`begin_call`'s own
265/// admission check), and `publish_transient` sends each exactly once as a
266/// single-envelope batch -- so this many slots covers the entire pending
267/// set without ever lagging the one subscriber that matters, the harness's
268/// own connection, the way sharing the durable `event_tx`'s bus could.
269const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
270const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
271const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
272const MAX_PREAUTH_CONNECTIONS: usize = 32;
273const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
274const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
275const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
276const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
277const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
278/// Fresh Spawn stays provisional until HQ successfully attaches (first Resize /
279/// TerminalBytes). This is an *attach-never-arrived* timeout from spawn time —
280/// not the disconnect reconnect grace (HQ owns that, starting only on a real
281/// client WS close). A provider conversation id is a session; a PTY with no
282/// attached HQ view is still a draft and is reaped here if attach never lands.
283const PROVISIONAL_SPAWN_GRACE_MS: u64 = 90_000;
284/// How often `drive_runtime_until_shutdown`'s loop re-evaluates the session
285/// record retention policy (`NodeServerConfig::session_record_retention`).
286/// A sweep cadence, not a policy value -- the policy itself lives entirely
287/// in the operator-configured age/keep-N flags; this just bounds how stale
288/// the in-memory view of dead `Unavailable` records can get between sweeps
289/// on an otherwise-idle node, mirroring the once-per-startup sweep that
290/// also runs alongside `reconcile_managed_worktrees`.
291const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
292/// TTL for `NodeShared::rejected_command_reasons` entries -- see that
293/// field's own doc for why a spawn waiter needs this at all. Must outlive
294/// the longest a waiter can still be polling for this exact command's
295/// outcome: `gate4agent_node_protocol::MAX_SPAWN_DEADLINE_MS` (120s) is the
296/// largest `deadline_ms` any caller may request, and the margin on top
297/// absorbs the waiter's own poll cadence plus clock skew between the drive
298/// loop (writer) and the waiter (reader).
299const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
300/// Bounds `NodeShared::rejected_command_reasons` against an unbounded
301/// backlog of asynchronously-rejected commands nobody ever collects (a
302/// waiter that stops polling, or a burst of always-rejected commands from a
303/// misbehaving caller): once at capacity, the oldest entry is evicted to
304/// make room for the newest, mirroring the eviction rule already used by
305/// `spawn_idempotency`.
306const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
307/// How long `wait_until_removed` waits between two `Remove` re-dispatches
308/// for the same session. The presence poll runs far faster (2ms); this
309/// only paces the sending, so an accepted `Remove` still in flight is not
310/// piled on by a dozen more that land on an already-dropped instance.
311const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
312const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
313const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
314const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
315const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
316const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
317const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
318const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
319const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
320const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
321const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
322const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
323const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
324/// Default total wall-clock budget for `inspect_workspace`'s walk+git
325/// chain, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS`.
326const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
327/// The harness relay enforces its own flat response deadline
328/// (`HOST_RUN_READ_RESPONSE_DEADLINE`, `hatchery-harness-service::runtime`,
329/// 12s) above this node-side budget. The two constants live in different
330/// crates with no shared definition, so clamp any override here comfortably
331/// below that external ceiling — a misconfigured value above it would
332/// reopen the exact deadline race this budget exists to close.
333const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
334/// Default cap on directory entries visited (not just returned) by one
335/// walk, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP`.
336const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
337const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
338const GIT_STATUS_MAX_ENTRIES: usize = 128;
339const GIT_COMMIT_MAX_ENTRIES: usize = 12;
340const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
341const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
342const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
343const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
344const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
345const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
346const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
347const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
348const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
349const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
350    "provider-session-identity-allocation-failed";
351const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
352const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
353const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
354const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
355const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
356const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
357const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
358    "durable-state-path-semantics-unsupported";
359
360fn opaque_windows_path(value: String) -> OpaqueHostPath {
361    OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
362}
363
364fn agent_progress_from_provider_snapshot(
365    address: SessionAddress,
366    provider: &ProviderSnapshot,
367) -> Option<SessionAgentProgress> {
368    let mut truncated = false;
369    let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
370    let mut active_tool_labels = Vec::with_capacity(
371        provider
372            .active_tools
373            .len()
374            .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
375    );
376    for tool in &provider.active_tools {
377        if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
378            truncated = true;
379            break;
380        }
381        if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
382            active_tool_labels.push(label);
383        }
384    }
385    let attention = provider
386        .interactions
387        .iter()
388        .find(|interaction| {
389            matches!(
390                interaction.status,
391                ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
392            )
393        })
394        .map(|interaction| AgentProgressAttentionV1 {
395            kind: match interaction.interaction_kind {
396                ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
397                ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
398            },
399            tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
400        });
401    let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
402    let usage = (provider.completed_turns > 0
403        || provider.usage.input_tokens > 0
404        || provider.usage.output_tokens > 0
405        || provider.usage.cache_read_tokens > 0
406        || provider.usage.cache_write_tokens > 0
407        || provider.usage.reasoning_tokens > 0)
408        .then_some(AgentProgressUsageV1 {
409            input_tokens: provider.usage.input_tokens,
410            output_tokens: provider.usage.output_tokens,
411            cache_read_tokens: provider.usage.cache_read_tokens,
412            cache_write_tokens: provider.usage.cache_write_tokens,
413            reasoning_tokens: provider.usage.reasoning_tokens,
414        });
415    let progress = AgentProgressV1 {
416        provider_sequence: provider.sequence,
417        activity: provider.activity,
418        completed_turns: provider.completed_turns,
419        usage,
420        current: AgentProgressCurrentV1::from(provider.activity),
421        active_tool_labels,
422        active_tool_count,
423        attention,
424        subagent_count,
425        last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
426        gap_count: provider.gap_count,
427        stale: provider.stale,
428        truncated,
429    };
430    let entry = SessionAgentProgress { address, progress };
431    serde_json::to_vec(&entry)
432        .ok()
433        .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
434        .map(|_| entry)
435}
436
437fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
438    match u32::try_from(count) {
439        Ok(count) => count,
440        Err(_) => {
441            *truncated = true;
442            u32::MAX
443        }
444    }
445}
446
447fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
448    if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
449        return None;
450    }
451    Some(match event {
452        ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
453        ProviderEvent::SessionIdentityObserved { .. } => {
454            AgentProgressEventKindV1::SessionIdentityObserved
455        }
456        ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
457        ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
458        ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
459        ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
460        ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
461        ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
462        ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
463        ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
464        ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
465        ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
466        ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
467        ProviderEvent::InteractionRequested { .. } => {
468            AgentProgressEventKindV1::InteractionRequested
469        }
470        ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
471        ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
472        ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
473        ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
474        ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
475        ProviderEvent::UnrecognizedNotification { .. } => {
476            AgentProgressEventKindV1::UnrecognizedNotification
477        }
478        ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
479        // ACP session/update coverage beyond text/tool/turn streaming
480        // (`plan`, `available_commands_update`, `current_mode_update`,
481        // `session_info_update`, `usage_update`, `config_option_update`,
482        // `user_message_chunk`). `AgentProgressEventKindV1` is a versioned
483        // wire enum without a `#[serde(other)]` fallback; minting new
484        // variants for it is a deliberate wire-contract decision for
485        // whoever owns this protocol, not a side effect of parsing more of
486        // ACP's own wire -- until that decision is made, these fold into
487        // the same "we saw something, no dedicated kind for it yet" bucket
488        // as `UnrecognizedNotification`.
489        ProviderEvent::UserMessage { .. }
490        | ProviderEvent::Plan { .. }
491        | ProviderEvent::AvailableCommandsUpdated { .. }
492        | ProviderEvent::ModeChanged { .. }
493        | ProviderEvent::SessionInfoUpdated { .. }
494        | ProviderEvent::UsageUpdated { .. }
495        | ProviderEvent::ConfigOptionsUpdated { .. } => {
496            AgentProgressEventKindV1::UnrecognizedNotification
497        }
498    })
499}
500
501/// Maps this harness's own `HostDecisionAuthority` onto the wider
502/// Maps a host request decider onto the `BlockAuthorityV1` a `Blocked` stream
503/// chunk carries -- the four authorities a `Denied` decision is ever made
504/// under, one-for-one. The remaining `BlockAuthorityV1` variants
505/// (`ProviderClassifier`, `ProviderPermissionRule`, `ProviderSandbox`,
506/// `ProviderRefusal`, `UserRejected`, `ProviderQuota`, `Unknown`) describe a
507/// block the PROVIDER side of the wire reported -- this function never
508/// produces one, because `ProviderHostDecisionAuthority` has no vocabulary for
509/// them; the provider-side blocks below mint those directly from typed fields.
510/// Whether a provider source is a live session transport -- the only kind
511/// whose events reach the agent stream. History, resume, session-option and
512/// capability-probe sources are not sessions and stream nothing.
513fn provider_family_is_live(family: AdapterFamily) -> bool {
514    match family {
515        AdapterFamily::PtySemantic
516        | AdapterFamily::Hook
517        | AdapterFamily::ManagedHook
518        | AdapterFamily::Pipe
519        | AdapterFamily::OneShot
520        | AdapterFamily::Acp => true,
521        AdapterFamily::History
522        | AdapterFamily::Resume
523        | AdapterFamily::SessionOptions
524        | AdapterFamily::CapabilityProbe => false,
525    }
526}
527
528fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
529    match by {
530        ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
531        ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
532        ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
533        ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
534    }
535}
536
537/// The `Blocked::reason` for a `Denied` decision this build's own
538/// `AgentEvent::RpcIncomingRequest::reason` did not attach text to --
539/// reachable today for `Operator`/`HostDeadline`: a deferred `session/
540/// request_permission` request resolved by `AcpSession::
541/// resolve_pending_request`/`expire_deadlines` (`src/acp/session.rs`),
542/// neither of which computes free text (an operator answers approve/reject,
543/// never a comment; an unattended deadline asks nobody anything). `reason`
544/// is a REQUIRED field on the wire (see `AgentStreamChunkKindV1::Blocked`)
545/// -- a categorical fallback naming the authority is the
546/// honest minimum, never a placeholder standing in for detail nobody
547/// reported.
548fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
549    match authority {
550        BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
551        BlockAuthorityV1::HostPolicy => "denied by host policy",
552        BlockAuthorityV1::HostDeadline => {
553            "denied by host policy after the operator deadline expired"
554        }
555        BlockAuthorityV1::Operator => "declined by the operator",
556        BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
557        BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
558        BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
559        BlockAuthorityV1::ProviderRefusal => "refused by the provider",
560        BlockAuthorityV1::UserRejected => "rejected by the user",
561        BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
562        BlockAuthorityV1::Unknown => "blocked; no reason was reported",
563    }
564}
565
566/// Computes `AgentStreamChunkKindV1::Blocked`'s `tool_class`/`authority`/`reason`
567/// for a `HostRequestObserved` `Denied` decision.
568fn host_request_denied_block(
569    method: &str,
570    by: ProviderHostDecisionAuthority,
571    reason: Option<&str>,
572) -> (String, BlockAuthorityV1, String) {
573    let authority = block_authority(by);
574    let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
575    // `AgentStreamChunkKindV1::validate` REJECTS an over-long reason outright
576    // rather than truncating -- this cut happens here, at the producer, before
577    // the validator ever sees the text.
578    let (reason_text, _) =
579        truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
580    (tool_class_label(method), authority, reason_text)
581}
582
583// ---------------------------------------------------------------------------
584// K2 -- provider-side blocks read off ACP `tool_call_update._meta.
585// nonExecutionKind` (`ProviderEvent::ToolCompleted::non_execution_kind`) and
586// `SessionEnded::stop_reason` (`stopReason: "refusal"`, or a `session/
587// prompt` RPC error whose vendor code names a quota/limit/rate
588// exhaustion). See
589// `docs/gate4agent/plans/gate4agent-blocked-action-event-2026-09-02.md`
590// §3 slice K2.
591// ---------------------------------------------------------------------------
592
593/// Classifies ACP's `nonExecutionKind` into the typed authority a block
594/// this build mints under, or `None` when the tool truly did not run for a
595/// reason that is NOT a block -- `"interrupted"`/`"cancelled"` mean the
596/// TURN stopped (session closed, the operator cancelled), not that a
597/// policy/classifier/rule refused the call, so those (and any unrecognized
598/// future value) stay a plain `ToolCompleted { success: false }` only, per
599/// this plan's own rule that a tool failure is never inflated into a block
600/// this build cannot actually attribute.
601fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
602    match kind? {
603        "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
604        "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
605        _ => None,
606    }
607}
608
609/// Splits a provider's own denial sentence into `reason`/`help`, when it
610/// matches the fixed shape Claude Code's classifier/permission-rule denial
611/// uses -- verified live, the owner's sample fixture
612/// (`docs/gate4agent/research/gate4agent-blocked-action-signals-2026-09-02.md`
613/// appendix): "...denied by <authority>. Reason: <reason>. <help...>".
614/// `authority` is NEVER derived from this text (see this module's own rule
615/// that authority comes only from a typed field) -- only `reason`/`help`
616/// are. Falls back to the full text as `reason` with no `help` when the
617/// shape does not match (a provider whose text does not carry this exact
618/// wording) -- the honest minimum: the tool truly did not run, so SOME
619/// reason is always reported, never fabricated detail beyond what arrived.
620fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
621    let trimmed = text.trim();
622    let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
623        return (trimmed.to_owned(), None);
624    };
625    let tail = after_reason.trim_start();
626    let Some((reason, help)) = tail.split_once(". ") else {
627        // "Reason:" present but no ". " to split help off -- the whole
628        // tail is the reason, e.g. "Reason: Blocked by classifier." with
629        // no help text following on some providers/versions.
630        let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
631        return (reason.to_owned(), None);
632    };
633    let help = help.trim();
634    if help.is_empty() {
635        (reason.trim().to_owned(), None)
636    } else {
637        (reason.trim().to_owned(), Some(help.to_owned()))
638    }
639}
640
641/// `tool_class` for a provider-side `ActionBlocked` this build has no typed
642/// tool name for -- `ProviderEvent::ToolCompleted` carries only `id`, never
643/// `name` (see `ProviderEvent::ToolStarted`, which does, but a tool's own
644/// name is never threaded through to its completion). Reads the class off
645/// the guidance tail's own "add a <Class> permission rule" sentence when
646/// present (the shape the owner's sample fixture uses) -- text matching
647/// fills `tool_class` here, same as `reason`/`help`, NEVER `authority` (see
648/// this module's own rule). Falls back to the same generic `"Tool"` class
649/// `ToolCompleted`'s own observation already uses when nothing names one.
650fn tool_class_from_denial_help(help: Option<&str>) -> String {
651    help.and_then(|text| text.split("add a ").nth(1))
652        .and_then(|tail| tail.split(' ').next())
653        .filter(|word| !word.is_empty())
654        .map(str::to_owned)
655        .unwrap_or_else(|| "Tool".to_owned())
656}
657
658/// Whether a `session/prompt` RPC error's vendor code (or, absent one, its
659/// message) names a quota/rate-limit exhaustion -- Codex's
660/// `data.codexErrorInfo: "usageLimitExceeded"` (measured live 2026-09-05)
661/// contains "limit", the word this build actually keys on; "quota"/"rate"
662/// are kept for whichever OTHER vendor's own code names one of those
663/// instead, never confirmed live, but the honest generalization of the
664/// one confirmed sample.
665fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
666    fn names_quota(s: &str) -> bool {
667        let lower = s.to_ascii_lowercase();
668        lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
669    }
670    vendor_code.is_some_and(names_quota) || names_quota(message)
671}
672
673/// The `Blocked` chunk this build mints when a turn stopped via a
674/// typed provider fact rather than a tool call -- `stopReason: "refusal"`
675/// (`authority: ProviderRefusal`) or a `session/prompt` RPC error whose
676/// vendor code (or message) names a quota/limit/rate exhaustion
677/// (`authority: ProviderQuota`, `reason` = the error message verbatim,
678/// `reason_kind` = the vendor code). `None` for every other stop --
679/// `end_turn`, `max_tokens`, `max_turn_requests`, `cancelled`, an untagged
680/// `Other`, or an RPC error whose vendor code/message names none of the
681/// three words: `SessionEnded` stays a plain `Exited`/turn-end fact for
682/// those, never inflated into a block this build cannot actually
683/// attribute. `ProviderRefusal`'s `reason` has no source text to read on
684/// `SessionEnded` itself (ACP's `refusal` stopReason carries none) -- the
685/// honest minimum names the fact that happened, never invented detail; a
686/// later slice that threads the turn's last agent text chunk through can
687/// replace it without changing this function's contract.
688fn provider_session_end_block(
689    stop_reason: &ProviderStopReason,
690) -> Option<(BlockAuthorityV1, String, String)> {
691    match stop_reason {
692        ProviderStopReason::Refusal => Some((
693            BlockAuthorityV1::ProviderRefusal,
694            "refusal".to_owned(),
695            "the provider ended the turn with stopReason: refusal".to_owned(),
696        )),
697        ProviderStopReason::ProviderError { message, vendor_code, .. }
698            if provider_error_is_quota(vendor_code.as_deref(), message) =>
699        {
700            let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
701            Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
702        }
703        _ => None,
704    }
705}
706
707/// Builds the `AgentStreamChunkV1` `event`'s `ProviderEvent` carries onto the
708/// `agent-stream-events-v1` channel -- see
709/// `docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
710/// §5. Two invariants keep the chunk correlatable with the `NodeEvent::Control`
711/// event it derives from, so a client joining the two sees one id and one
712/// ordering:
713///
714/// - `source_sequence` on the chunk is the `source_sequence` the provider
715///   event itself carries.
716/// - `InteractionPrompt::correlation_id` is minted by
717///   `correlation::interaction_correlation` with the
718///   `(instance_id, generation, provider_sequence)` triple -- never the
719///   provider's own `request_id`, whose opacity on the wire is pinned by a
720///   dedicated test.
721///
722/// Returns `None` for every `ControlEventKind` other than `ProviderEvent`
723/// (nothing else carries stream content), for a source that is not a live
724/// session transport, and for the `ProviderEvent` variants below with no
725/// declared chunk kind to carry them, or whose declared chunk kind this build
726/// has no source for yet:
727///
728/// - `ModelCatalog { current, available }` has no `ProviderEvent` source at
729///   all -- grok's vendor model switch is not bridged into `ProviderEvent`
730///   yet, the same gap the plan names explicitly.
731/// - `UserMessage { text, is_delta }` is an echo of the OPERATOR's own
732///   prompt, replayed on session resume -- `Text`/`Thinking` are documented
733///   as "what the agent is saying", so mapping the operator's words onto
734///   `Text` would misrepresent whose content it is. No dedicated
735///   user-message kind is declared, so this stays unproduced rather than
736///   mislabeled.
737///
738/// `HostRequestObserved { .. }` is its own arm below: a `Granted`/`Deferred`
739/// decision returns `None` (nothing for the operator to react to), a `Denied`
740/// decision returns `Some(Blocked { .. })`.
741///
742/// `ToolCompleted { .. }`/`SessionEnded { .. }` are likewise their own arms
743/// below (K2): each returns `None` unless
744/// `provider_tool_block_authority`/`provider_session_end_block` classifies the
745/// event as an actual block (ACP's `_meta.nonExecutionKind`, or `stopReason`/an
746/// RPC error naming a quota).
747fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
748    let ControlEventKind::ProviderEvent {
749        sequence: provider_sequence,
750        source,
751        source_sequence,
752        event: provider_event,
753    } = &event.event
754    else {
755        return None;
756    };
757    if !provider_family_is_live(source.family) {
758        return None;
759    }
760    let kind = match provider_event {
761        ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
762            text: text.clone(),
763            is_delta: *is_delta,
764        },
765        ProviderEvent::Thinking { text } => {
766            AgentStreamChunkKindV1::Thinking { text: text.clone() }
767        }
768        ProviderEvent::InteractionRequested {
769            interaction_kind,
770            tool_name,
771            title,
772            prompt,
773            options,
774            ..
775        } => AgentStreamChunkKindV1::InteractionPrompt {
776            correlation_id: interaction_correlation(
777                event.instance_id,
778                event.generation,
779                *provider_sequence,
780            ),
781            interaction_kind: *interaction_kind,
782            tool_name: tool_name.clone(),
783            // `title`/`options` are carried straight through from
784            // `ProviderEvent::InteractionRequested`, as the agent itself
785            // gave them on `session/request_permission`'s `PermissionTool
786            // Call.title`/`options` (see `gate4agent-shell-native`'s
787            // `provider_event`, the only ACP-transport producer). A PTY- or
788            // hook-sourced interaction carries `title: None` and
789            // `options: []` from its own producer because that source has
790            // none to offer, never because this mapping dropped one.
791            title: title.clone(),
792            prompt: prompt.clone(),
793            options: options
794                .iter()
795                .map(|option| AgentStreamInteractionOptionV1 {
796                    option_id: option.option_id.clone(),
797                    name: option.name.clone(),
798                    kind: option.kind.clone(),
799                })
800                .collect(),
801        },
802        ProviderEvent::ConfigOptionsUpdated { options } => {
803            AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
804        }
805        // `available` is the mode catalogue `AcpSession::available_modes()`
806        // read back at `session/new` and threaded through by
807        // `gate4agent-shell-native`'s `provider_event` (see that crate's
808        // `ProviderEvent::ModeChanged` arm) -- an empty list here is the
809        // agent's own honest "no modes announced", never "not read": ACP
810        // orders `session/new` before any `session/update`, and the
811        // catalogue is fixed at handshake, so a `ModeChanged` event cannot
812        // exist before the same session object's catalogue is already
813        // populated (see `gate4agent_types::ProviderEvent::ModeChanged`'s
814        // own doc comment for the ordering argument in full).
815        ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
816            current: Some(mode_id.clone()),
817            available: available
818                .iter()
819                .map(|mode| AgentStreamNamedIdV1 {
820                    id: mode.id.clone(),
821                    name: mode.name.clone(),
822                    description: mode.description.clone(),
823                })
824                .collect(),
825        },
826        // A denied host request is a block -- see this function's own doc
827        // comment for why `HostRequestObserved` is not in the exclusion list
828        // below.
829        ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
830            let ProviderHostRequestDecision::Denied { by } = decision else {
831                return None;
832            };
833            let (tool_class, authority, reason_text) =
834                host_request_denied_block(method, *by, reason.as_deref());
835            AgentStreamChunkKindV1::Blocked {
836                correlation_id: None,
837                tool_class,
838                authority,
839                reason_kind: None,
840                reason: reason_text,
841                help: None,
842            }
843        }
844        // Same relationship as `HostRequestObserved` above, driven by ACP's
845        // own `_meta.nonExecutionKind` instead of the host's
846        // `HostRequestDecision`.
847        ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
848            let (authority, reason_kind) =
849                provider_tool_block_authority(non_execution_kind.as_deref())?;
850            let (reason, help) = split_provider_denial_text(output);
851            let (reason, _) =
852                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
853            let help = help.map(|help| {
854                truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
855            });
856            AgentStreamChunkKindV1::Blocked {
857                correlation_id: Some(tool_correlation(event, source, id)),
858                tool_class: tool_class_from_denial_help(help.as_deref()),
859                authority,
860                reason_kind: Some(reason_kind.to_owned()),
861                reason,
862                help,
863            }
864        }
865        // Same relationship, driven by `stopReason: "refusal"` or a
866        // `session/prompt` RPC error naming a quota/limit/rate exhaustion
867        // instead of a tool-level fact.
868        ProviderEvent::SessionEnded { stop_reason, .. } => {
869            let (authority, reason_kind, reason) =
870                stop_reason.as_ref().and_then(provider_session_end_block)?;
871            let (reason, _) =
872                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
873            AgentStreamChunkKindV1::Blocked {
874                correlation_id: None,
875                tool_class: "Session".to_owned(),
876                authority,
877                reason_kind: Some(reason_kind),
878                reason,
879                help: None,
880            }
881        }
882        ProviderEvent::SessionStarted { .. }
883        | ProviderEvent::SessionIdentityObserved { .. }
884        | ProviderEvent::TurnStarted { .. }
885        | ProviderEvent::WorkingObserved
886        | ProviderEvent::ToolStarted { .. }
887        | ProviderEvent::TurnCompleted { .. }
888        | ProviderEvent::ContextWindowUsage { .. }
889        | ProviderEvent::TurnInterrupted
890        | ProviderEvent::Error { .. }
891        | ProviderEvent::Ready
892        | ProviderEvent::InteractionResolved { .. }
893        | ProviderEvent::SubagentStarted { .. }
894        | ProviderEvent::SubagentStopped { .. }
895        | ProviderEvent::RateLimited { .. }
896        | ProviderEvent::UnrecognizedNotification { .. }
897        | ProviderEvent::UserMessage { .. }
898        | ProviderEvent::Plan { .. }
899        | ProviderEvent::AvailableCommandsUpdated { .. }
900        | ProviderEvent::SessionInfoUpdated { .. }
901        | ProviderEvent::UsageUpdated { .. } => return None,
902    };
903    Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
904}
905
906async fn run_native_session_catalog_operation<T, E, F>(
907    catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
908    operation: &'static str,
909    execute: F,
910) -> Result<T, NodeFailure>
911where
912    T: Send + 'static,
913    E: std::fmt::Display + Send + 'static,
914    F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
915{
916    let task = tokio::task::spawn_blocking(move || {
917        let mut catalog = match catalog.try_lock() {
918            Ok(catalog) => catalog,
919            Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
920            Err(std::sync::TryLockError::WouldBlock) => {
921                return Err(failure(
922                    NodeFailureCode::BackendBusy,
923                    "native session catalog operation is already running",
924                ));
925            }
926        };
927        execute(&mut catalog).map_err(|error| {
928            failure(
929                NodeFailureCode::BackendOperationFailed,
930                &format!("{operation} failed: {error}"),
931            )
932        })
933    });
934    timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
935        .await
936        .map_err(|_| {
937            failure(
938                NodeFailureCode::BackendBusy,
939                &format!("{operation} exceeded its bounded deadline"),
940            )
941        })?
942        .map_err(|_| {
943            failure(
944                NodeFailureCode::BackendOperationFailed,
945                &format!("{operation} task failed"),
946            )
947        })?
948}
949
950fn windows_path_text(path: &OpaqueHostPath) -> &str {
951    path.as_utf8()
952        .expect("Windows node only creates UTF-8 opaque host paths")
953}
954
955fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
956    path.as_utf8()
957        .map(str::to_owned)
958        .ok_or_else(|| failure(
959            NodeFailureCode::InvalidRequest,
960            "this node requires UTF-8 host paths",
961        ))
962}
963
964fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
965    GitWorktreeSnapshot {
966        path: opaque_windows_path(worktree.path),
967        head: worktree.head,
968        branch: worktree.branch,
969        is_bare: worktree.is_bare,
970        is_main: worktree.is_main,
971        locked: worktree.locked,
972        lock_reason: worktree.lock_reason,
973        prunable: worktree.prunable,
974        prunable_reason: worktree.prunable_reason,
975        workspace_id: worktree.workspace_id.take(),
976    }
977}
978
979#[derive(Clone, Debug, Eq, PartialEq)]
980pub struct WorkspaceConfig {
981    workspace_id: WorkspaceId,
982    canonical_root: String,
983    worktree_service_mode: WorktreeServiceMode,
984    managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
985}
986
987impl WorkspaceConfig {
988    pub fn new(
989        workspace_id: WorkspaceId,
990        root: impl AsRef<Path>,
991    ) -> Result<Self, NodeServerError> {
992        let root = root.as_ref();
993        if !root.is_absolute() {
994            return Err(NodeServerError::InvalidWorkspaceRoot {
995                workspace_id,
996                path: root.to_string_lossy().into_owned(),
997                message: "path is not absolute".to_owned(),
998            });
999        }
1000        let canonical = std::fs::canonicalize(root).map_err(|error| {
1001            NodeServerError::InvalidWorkspaceRoot {
1002                workspace_id: workspace_id.clone(),
1003                path: root.to_string_lossy().into_owned(),
1004                message: error.to_string(),
1005            }
1006        })?;
1007        if !canonical.is_dir() {
1008            return Err(NodeServerError::InvalidWorkspaceRoot {
1009                workspace_id,
1010                path: root.to_string_lossy().into_owned(),
1011                message: "path is not a directory".to_owned(),
1012            });
1013        }
1014        let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
1015            NodeServerError::InvalidWorkspaceRoot {
1016                workspace_id: workspace_id.clone(),
1017                path: path.to_string_lossy().into_owned(),
1018                message: "canonical path is not valid Unicode".to_owned(),
1019            }
1020        })?;
1021        let canonical_root = platform::normalize_canonical_root(canonical_root);
1022        validate_workspace_root(&workspace_id, &canonical_root)?;
1023        Ok(Self {
1024            workspace_id,
1025            canonical_root,
1026            worktree_service_mode: WorktreeServiceMode::Manual,
1027            managed_worktree_profiles: BTreeMap::new(),
1028        })
1029    }
1030
1031    pub fn workspace_id(&self) -> &WorkspaceId {
1032        &self.workspace_id
1033    }
1034
1035    pub fn canonical_root(&self) -> &str {
1036        &self.canonical_root
1037    }
1038
1039    pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1040        self.worktree_service_mode = mode;
1041        self
1042    }
1043
1044    pub fn with_managed_worktree_profile(
1045        mut self,
1046        profile: ManagedWorktreeProfile,
1047    ) -> Result<Self, NodeServerError> {
1048        profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1049            NodeServerError::InvalidManagedWorktreeProfile {
1050                workspace_id: self.workspace_id.clone(),
1051                profile_id: profile.profile_id().clone(),
1052                message,
1053            }
1054        })?;
1055        if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1056            return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1057                workspace_id: self.workspace_id,
1058                profile_id: profile.profile_id().clone(),
1059            });
1060        }
1061        if self.managed_worktree_profiles.len()
1062            == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1063        {
1064            return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1065                workspace_id: self.workspace_id,
1066                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1067            });
1068        }
1069        self.managed_worktree_profiles
1070            .insert(profile.profile_id().clone(), profile);
1071        Ok(self)
1072    }
1073}
1074
1075#[derive(Clone)]
1076pub struct NodeServerConfig {
1077    pub endpoint: String,
1078    api_listen: Option<std::net::SocketAddr>,
1079    /// Opt-in node-envelope HTTP+WS browser bridge (separate from `--api-listen`
1080    /// ops HTTP and from framed C2). Loopback only; default `None` = off.
1081    bridge_listen: Option<std::net::SocketAddr>,
1082    /// Optional bridge shared secret (`GATE4AGENT_BRIDGE_TOKEN`). Distinct from
1083    /// NODE_TOKEN; never logged. `None` = loopback bind is the only gate.
1084    bridge_token: Option<String>,
1085    /// Tip 6: optional mesh underlay UDP bind that relays to local `--bridge-listen`.
1086    /// Requires bridge_listen + bridge_token. HQ never accepts (NodePeer).
1087    bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1088    /// A relay to CALL, rather than wait to be called by. `None` keeps the
1089    /// node purely a listener, which is what it has always been.
1090    call_home: Option<std::net::SocketAddr>,
1091    /// When set, call-home authenticates by signing the relay's challenge with this
1092    /// identity (enrolled node) and falls back to the token handshake against a relay
1093    /// that sends no challenge.
1094    call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1095    pub node_id: NodeId,
1096    pub workspaces: Vec<WorkspaceConfig>,
1097    access_token: String,
1098    pub runtime: NativeRuntimeConfig,
1099    session_record_retention: SessionRecordRetentionConfig,
1100    state_path: Option<PathBuf>,
1101    spawn_profiles: SpawnProfileRegistry,
1102    session_environment: Option<NodeSessionEnvironmentConfig>,
1103    history: Option<NativeHistoryConfig>,
1104    harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1105    /// Station network allowlist catalog (opaque ids). Empty default.
1106    /// Loaded at startup from `--network-allowlist-catalog` / env
1107    /// `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG` file. Never secrets.
1108    network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1109    #[cfg(feature = "fixture")]
1110    fixture_raw_pty_runtime: bool,
1111}
1112
1113#[derive(Clone)]
1114struct NodeSessionEnvironmentConfig {
1115    root: PathBuf,
1116    resolver: Arc<dyn NodeSecretResolver>,
1117}
1118
1119impl NodeServerConfig {
1120    pub fn new(
1121        endpoint: impl Into<String>,
1122        access_token: impl Into<String>,
1123        node_id: NodeId,
1124        workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1125    ) -> Result<Self, NodeServerError> {
1126        let endpoint = endpoint.into();
1127        let access_token = access_token.into();
1128        if !platform::validate_endpoint(&endpoint) {
1129            return Err(NodeServerError::InvalidEndpoint);
1130        }
1131        if access_token.is_empty() || access_token.len() > 4_096 {
1132            return Err(NodeServerError::InvalidAccessToken);
1133        }
1134        let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1135        if workspaces.is_empty() {
1136            return Err(NodeServerError::NoWorkspaces);
1137        }
1138        let mut workspace_ids = BTreeMap::new();
1139        let mut workspace_roots = BTreeMap::new();
1140        for workspace in &workspaces {
1141            if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1142                && workspace.managed_worktree_profiles.is_empty()
1143            {
1144                return Err(NodeServerError::ManagedWorktreeProfileRequired(
1145                    workspace.workspace_id.clone(),
1146                ));
1147            }
1148            if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1149                && !workspace.managed_worktree_profiles.is_empty()
1150            {
1151                return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1152                    workspace.workspace_id.clone(),
1153                ));
1154            }
1155            if workspace_ids
1156                .insert(workspace.workspace_id.clone(), ())
1157                .is_some()
1158            {
1159                return Err(NodeServerError::DuplicateWorkspaceId(
1160                    workspace.workspace_id.clone(),
1161                ));
1162            }
1163            let root_key = platform::root_identity(&workspace.canonical_root);
1164            if let Some(existing) = workspace_roots.insert(
1165                root_key,
1166                workspace.workspace_id.clone(),
1167            ) {
1168                return Err(NodeServerError::DuplicateWorkspaceRoot {
1169                    first: existing,
1170                    second: workspace.workspace_id.clone(),
1171                    root: workspace.canonical_root.clone(),
1172                });
1173            }
1174        }
1175        Ok(Self {
1176            endpoint,
1177            api_listen: None,
1178            bridge_listen: None,
1179            bridge_token: None,
1180            bridge_underlay: None,
1181            call_home: None,
1182            call_home_identity: None,
1183            node_id,
1184            workspaces,
1185            access_token,
1186            // PTY output is polled by the runtime's effect worker; the upstream
1187            // default (20ms) added ~10-20ms to every keystroke echo.
1188            runtime: NativeRuntimeConfig {
1189                worker_poll_interval_ms: 4,
1190                ..NativeRuntimeConfig::default()
1191            },
1192            session_record_retention: SessionRecordRetentionConfig::default(),
1193            state_path: None,
1194            spawn_profiles: SpawnProfileRegistry::default(),
1195            session_environment: None,
1196            history: None,
1197            harness_mcp_helper: None,
1198            network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1199            #[cfg(feature = "fixture")]
1200            fixture_raw_pty_runtime: false,
1201        })
1202    }
1203
1204    pub fn with_api_listen(
1205        mut self,
1206        api_listen: std::net::SocketAddr,
1207    ) -> Result<Self, NodeServerError> {
1208        if !api_listen.ip().is_loopback() {
1209            return Err(NodeServerError::InvalidApiListen(api_listen));
1210        }
1211        self.api_listen = Some(api_listen);
1212        Ok(self)
1213    }
1214
1215    /// Opt-in node-envelope HTTP+WS bridge listener. Loopback only (same spirit
1216    /// as `with_api_listen`). Does not enable by default — libraries and the
1217    /// binary leave this `None` until `--bridge-listen` / `with_bridge_listen`.
1218    pub fn with_bridge_listen(
1219        mut self,
1220        bridge_listen: std::net::SocketAddr,
1221    ) -> Result<Self, NodeServerError> {
1222        if !bridge_listen.ip().is_loopback() {
1223            return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1224        }
1225        self.bridge_listen = Some(bridge_listen);
1226        Ok(self)
1227    }
1228
1229    /// Optional bridge shared secret, distinct from the C2/node access token.
1230    /// Never log the value. Empty / oversize refused.
1231    pub fn with_bridge_token(
1232        mut self,
1233        bridge_token: impl Into<String>,
1234    ) -> Result<Self, NodeServerError> {
1235        let bridge_token = bridge_token.into();
1236        if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1237            return Err(NodeServerError::InvalidBridgeToken);
1238        }
1239        self.bridge_token = Some(bridge_token);
1240        Ok(self)
1241    }
1242
1243    /// Tip 6: point bridge reachability over mesh underlay (UDP+AEAD relay to
1244    /// local loopback `--bridge-listen`). Requires bridge listen + bridge token
1245    /// already configured. Transport key is 32 raw bytes (never logged).
1246    /// Underlay auth token is distinct from BRIDGE_TOKEN (crypto ≠ auth).
1247    pub fn with_bridge_underlay(
1248        mut self,
1249        bind: std::net::SocketAddr,
1250        transport_key: [u8; 32],
1251        underlay_token: impl Into<String>,
1252    ) -> Result<Self, NodeServerError> {
1253        if self.bridge_listen.is_none() {
1254            return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1255        }
1256        if self.bridge_token.is_none() {
1257            return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1258        }
1259        let underlay_token = underlay_token.into();
1260        if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1261            return Err(NodeServerError::InvalidBridgeUnderlayToken);
1262        }
1263        // Port 0 is allowed (ephemeral). Role lock (no HQ accept) lives in underlay.
1264        self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1265            bind,
1266            transport_key,
1267            underlay_token,
1268        });
1269        Ok(self)
1270    }
1271
1272    /// Also dial `relay` and serve this wire over the connection, in
1273    /// addition to accepting on the local endpoint.
1274    ///
1275    /// Who opens the socket and who is the protocol's server are separate
1276    /// questions, and this is the node's half of separating them. The node
1277    /// stays the server either way -- it answers `NodeRequest`s and emits
1278    /// `NodeEvent`s exactly as it does for a relay that dialled it. All
1279    /// that changes is that a node with no address anyone can reach makes
1280    /// the connection itself, and names itself once it arrives (see
1281    /// `gate4agent_node_wire::write_call_home_announce`).
1282    ///
1283    /// Loopback only, and that is not an oversight to be flagged and
1284    /// worked around. This wire carries terminal contents, keystrokes and
1285    /// file bytes as plaintext JSON; its mutual challenge-response
1286    /// authenticates both ends and encrypts nothing. Every listener in
1287    /// this stack is loopback-guarded for that reason, and a dialler must
1288    /// hold the same line -- a node calling a public relay would put the
1289    /// whole control plane on the wire in the clear. Reaching a relay on
1290    /// another host is a transport question, and it stays unanswered here
1291    /// on purpose: this change is about direction, not distance.
1292    pub fn with_call_home(
1293        mut self,
1294        relay: std::net::SocketAddr,
1295    ) -> Result<Self, NodeServerError> {
1296        // Loopback, or the concrete C2 WireGuard tunnel address. Unspecified
1297        // and port 0 refused — plaintext wire must not dial every interface.
1298        if relay.port() == 0 || relay.ip().is_unspecified() {
1299            return Err(NodeServerError::InvalidCallHome(relay));
1300        }
1301        self.call_home = Some(relay);
1302        Ok(self)
1303    }
1304
1305    /// Authenticate call-home with a node-held identity instead of a standing token.
1306    pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1307        self.call_home_identity = Some(identity);
1308        self
1309    }
1310
1311    pub fn with_state_path(
1312        mut self,
1313        state_path: impl Into<PathBuf>,
1314    ) -> Result<Self, NodeServerError> {
1315        let state_path = state_path.into();
1316        if !state_path.is_absolute() || state_path.file_name().is_none() {
1317            return Err(NodeServerError::InvalidStatePath(
1318                state_path.to_string_lossy().into_owned(),
1319            ));
1320        }
1321        self.state_path = Some(state_path);
1322        Ok(self)
1323    }
1324
1325    pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1326        self.spawn_profiles = spawn_profiles;
1327        self
1328    }
1329
1330    /// Sets the age/keep-N policy the drive loop's retention sweep uses to
1331    /// retire dead `ManagedSessionState::Unavailable` records. Both fields
1332    /// default to `0` (disabled) via `SessionRecordRetentionConfig::default`
1333    /// when this is never called -- a node never deletes a durable record
1334    /// until an operator opts in with a real value.
1335    pub fn with_session_record_retention(
1336        mut self,
1337        retention: SessionRecordRetentionConfig,
1338    ) -> Self {
1339        self.session_record_retention = retention;
1340        self
1341    }
1342
1343    pub fn with_session_environment_materialization(
1344        mut self,
1345        root: impl Into<PathBuf>,
1346        resolver: Arc<dyn NodeSecretResolver>,
1347    ) -> Result<Self, NodeServerError> {
1348        let root = root.into();
1349        if !root.is_absolute() {
1350            return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1351        }
1352        self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1353        Ok(self)
1354    }
1355
1356    /// Enables bounded provider-history discovery and loading from explicit
1357    /// host-owned roots. Ambient vendor homes are never inferred here.
1358    pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1359        self.history = Some(history);
1360        self
1361    }
1362
1363    /// Enables the H3B read proxy with one exact operator-reviewed helper file.
1364    /// Linux/macOS use owner-only UDS; Windows uses owner-only named pipes.
1365    /// Both platforms review the helper the same way (absolute regular file +
1366    /// identity hash) — the old `cfg(not(windows))` hard-refuse was a leftover
1367    /// gate after Unix listener/peer-cred support landed.
1368    pub fn with_harness_mcp_helper(
1369        mut self,
1370        helper_program: impl Into<PathBuf>,
1371    ) -> Result<Self, NodeServerError> {
1372        self.harness_mcp_helper = Some(
1373            ReviewedHarnessMcpProgram::review(helper_program.into())
1374                .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1375        );
1376        Ok(self)
1377    }
1378
1379    /// Install an in-memory station network allowlist catalog (opaque ids +
1380    /// optional node-local permits / provider-native mapping). Empty remains
1381    /// the default — unknown spawn ids refuse at resolve. Never cookies /
1382    /// OAuth / proxy credentials. C2 / inventory still list ids only.
1383    pub fn with_network_allowlist_catalog(
1384        mut self,
1385        catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1386    ) -> Result<Self, NodeServerError> {
1387        if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1388            return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1389                max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1390            });
1391        }
1392        self.network_allowlist_catalog = catalog;
1393        Ok(self)
1394    }
1395
1396    /// Load station network allowlist catalog from an absolute regular file
1397    /// (v1 id-list or v2 JSON schema). Unset path is not used here — callers
1398    /// that want env fallback use `resolve_network_allowlist_catalog`.
1399    pub fn with_network_allowlist_catalog_file(
1400        self,
1401        path: impl AsRef<std::path::Path>,
1402    ) -> Result<Self, NodeServerError> {
1403        let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1404            .map_err(NodeServerError::from)?;
1405        self.with_network_allowlist_catalog(catalog)
1406    }
1407}
1408
1409pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1410    platform::default_state_path(node_id.as_str())
1411        .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1412}
1413
1414pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1415    platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1416}
1417
1418fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1419    let parent = state_path.parent()?;
1420    let mut name = state_path.file_name()?.to_os_string();
1421    name.push(".delivery-store");
1422    Some(parent.join(name))
1423}
1424
1425fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1426    let parent = state_path.parent()?;
1427    let mut name = state_path.file_name()?.to_os_string();
1428    name.push(".context-pack-store");
1429    Some(parent.join(name))
1430}
1431
1432pub struct NodeServer {
1433    config: NodeServerConfig,
1434    runtime: NativeRuntime,
1435    shared: Arc<NodeShared>,
1436    events: EventSubscription,
1437    state_path_lock: Option<session_registry::StatePathLock>,
1438}
1439
1440#[cfg(feature = "fixture")]
1441#[derive(Clone, Default)]
1442pub struct SpawnManagedWorktreeV2FailureProbe {
1443    latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1444}
1445
1446#[cfg(feature = "fixture")]
1447impl SpawnManagedWorktreeV2FailureProbe {
1448    pub fn latest_code(&self) -> Option<NodeFailureCode> {
1449        *self
1450            .latest_code
1451            .lock()
1452            .unwrap_or_else(|poisoned| poisoned.into_inner())
1453    }
1454
1455    fn record(&self, code: Option<NodeFailureCode>) {
1456        *self
1457            .latest_code
1458            .lock()
1459            .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1460    }
1461}
1462
1463#[cfg(all(feature = "fixture", windows))]
1464struct IsolatedCodexHomeFixtureEnvironment;
1465
1466#[cfg(all(feature = "fixture", windows))]
1467impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1468    for IsolatedCodexHomeFixtureEnvironment
1469{
1470    fn resolve_child_environment(
1471        &self,
1472    ) -> Result<
1473        Vec<gate4agent_catalog::EnvMutation>,
1474        gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1475    > {
1476        Ok(vec![gate4agent_catalog::EnvMutation {
1477            key: OsString::from("USERPROFILE"),
1478            value: None,
1479        }])
1480    }
1481}
1482
1483impl NodeServer {
1484    /// The letter-wake handle for the door, when m4a accounts are on. `token` is what callers
1485    /// must present.
1486    /// Turn on the m4a signer proxy for PTY spawns (replaces `M4A_SESSION_CAP`).
1487    #[cfg(unix)]
1488    pub fn set_signer_proxy(&self, proxy: Arc<crate::keystore_proxy::SignerProxy>) -> bool {
1489        self.shared.signer_proxy.set(proxy).is_ok()
1490    }
1491
1492    pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1493        self.shared.m4a_accounts.as_ref()?;
1494        Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1495    }
1496
1497    pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1498        let catalog = active_registry()?;
1499        Self::new_with_registry(config, catalog)
1500    }
1501
1502    #[cfg(feature = "fixture")]
1503    pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1504        let mut spec = gate4agent_testkit::interactive_agent_spec();
1505        spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1506        spec.display_name = "Controlled Claude PTY fixture".to_owned();
1507        Self::new_fixture_with_spec(config, spec)
1508    }
1509
1510    #[cfg(feature = "fixture")]
1511    pub fn new_clean_exit_fixture(
1512        config: NodeServerConfig,
1513        fixture_root: PathBuf,
1514        started_marker: PathBuf,
1515        release_signal: PathBuf,
1516    ) -> Result<Self, NodeServerError> {
1517        let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1518            &fixture_root,
1519            &started_marker,
1520            &release_signal,
1521        )
1522        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1523        spec.id = AgentId::new("claude")
1524            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1525        spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1526        Self::new_fixture_with_spec(config, spec)
1527    }
1528
1529    /// A `claude` source whose session establishes a verified provider
1530    /// identity via an early `SessionStart` Hook post (so it is eligible for
1531    /// auto-export-at-exit and its native history is independently
1532    /// loadable), waits for `release_signal`, then exits cleanly — paired
1533    /// on the same Node with a plain `codex` target that never validates
1534    /// anything about what it receives. Built for A2 durable ContextPack
1535    /// E2E coverage: the test itself inspects materialized files and
1536    /// managed-session-record fields directly, rather than having the
1537    /// target script self-validate.
1538    #[cfg(all(feature = "fixture", windows))]
1539    pub fn new_durable_context_pack_clean_exit_fixture(
1540        config: NodeServerConfig,
1541        fixture_root: PathBuf,
1542        started_marker: PathBuf,
1543        release_signal: PathBuf,
1544        session_id: &str,
1545    ) -> Result<Self, NodeServerError> {
1546        let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1547            &fixture_root,
1548            &started_marker,
1549            &release_signal,
1550            session_id,
1551        )
1552        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1553        let source_id = AgentId::new("claude")
1554            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1555        let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1556            NodeServerError::Registry(
1557                "durable context pack clean-exit fixture source adapter is unavailable"
1558                    .to_owned(),
1559            )
1560        })?;
1561        source_spec.capabilities.adapters.history =
1562            source_provider.capabilities.adapters.history.clone();
1563        source_spec.id = source_id;
1564        source_spec.display_name =
1565            "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1566        // Both specs launch via `powershell.exe`; the registry's ambiguity
1567        // check keys on `detection.command` (+ aliases), not `launch.program`
1568        // -- give each a distinct detection identity, same precedent as
1569        // `context_pack_fixture_catalog`'s per-provider
1570        // `gate4agent-{provider}-context-fixture.cmd` commands.
1571        source_spec.detection.command =
1572            "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1573        source_spec.detection.aliases.clear();
1574
1575        let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1576        target_spec.id = AgentId::new("codex")
1577            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1578        target_spec.display_name =
1579            "Controlled Codex durable context-pack target fixture".to_owned();
1580        target_spec.detection.command =
1581            "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1582        target_spec.detection.aliases.clear();
1583
1584        let catalog = AgentRegistry::new([source_spec, target_spec])
1585            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1586        let mut server = Self::new_with_registry(config, catalog)?;
1587        Arc::get_mut(&mut server.shared)
1588            .expect("fixture Node shared state must remain uniquely owned before run")
1589            .fixture_semantic_hook_policy = true;
1590        Ok(server)
1591    }
1592
1593    #[cfg(feature = "fixture")]
1594    pub fn new_monitoring_hook_fixture(
1595        config: NodeServerConfig,
1596    ) -> Result<Self, NodeServerError> {
1597        let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1598        spec.id = AgentId::new("claude")
1599            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1600        let mut server = Self::new_fixture_with_spec(config, spec)?;
1601        Arc::get_mut(&mut server.shared)
1602            .expect("fixture Node shared state must remain uniquely owned before run")
1603            .fixture_semantic_hook_policy = true;
1604        Ok(server)
1605    }
1606
1607    #[cfg(all(feature = "fixture", windows))]
1608    pub fn new_provider_bundle_argv_fixture(
1609        config: NodeServerConfig,
1610        agent_id: AgentId,
1611        proof_path: PathBuf,
1612    ) -> Result<Self, NodeServerError> {
1613        let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1614        Self::new_fixture_with_spec(config, spec)
1615    }
1616
1617    #[cfg(all(feature = "fixture", windows))]
1618    pub fn new_provider_bundle_argv_hold_fixture(
1619        config: NodeServerConfig,
1620        agent_id: AgentId,
1621        proof_path: PathBuf,
1622        release_signal: PathBuf,
1623    ) -> Result<Self, NodeServerError> {
1624        let spec = Self::provider_bundle_argv_fixture_spec(
1625            agent_id,
1626            proof_path,
1627            Some(release_signal),
1628        )?;
1629        Self::new_fixture_with_spec(config, spec)
1630    }
1631
1632    #[cfg(all(feature = "fixture", windows))]
1633    fn provider_bundle_argv_fixture_spec(
1634        agent_id: AgentId,
1635        proof_path: PathBuf,
1636        release_signal: Option<PathBuf>,
1637    ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1638        if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1639            return Err(NodeServerError::Registry(
1640                "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1641            ));
1642        }
1643        let mut spec = gate4agent_testkit::interactive_agent_spec();
1644        spec.id = agent_id.clone();
1645        spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1646        let provider = builtin_registry()
1647            .get(&agent_id)
1648            .ok_or_else(|| {
1649                NodeServerError::Registry(
1650                    "provider bundle argv fixture adapter is unavailable".to_owned(),
1651                )
1652            })?;
1653        spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1654        if !proof_path.is_absolute()
1655            || !proof_path
1656                .parent()
1657                .is_some_and(|parent| parent.is_dir())
1658        {
1659            return Err(NodeServerError::Registry(
1660                "provider bundle argv proof path requires an existing absolute parent"
1661                    .to_owned(),
1662            ));
1663        }
1664        if let Some(release_signal) = release_signal.as_ref() {
1665            if !release_signal.is_absolute()
1666                || !release_signal
1667                    .parent()
1668                    .is_some_and(|parent| parent.is_dir())
1669            {
1670                return Err(NodeServerError::Registry(
1671                    "provider bundle argv release path requires an existing absolute parent"
1672                        .to_owned(),
1673                ));
1674            }
1675            if release_signal.exists() {
1676                return Err(NodeServerError::Registry(
1677                    "provider bundle argv release path must not exist".to_owned(),
1678                ));
1679            }
1680            let proof_parent = std::fs::canonicalize(
1681                proof_path
1682                    .parent()
1683                    .expect("validated provider bundle proof parent"),
1684            )
1685            .map_err(|_| {
1686                NodeServerError::Registry(
1687                    "provider bundle argv proof parent is unavailable".to_owned(),
1688                )
1689            })?;
1690            let release_parent = std::fs::canonicalize(
1691                release_signal
1692                    .parent()
1693                    .expect("validated provider bundle release parent"),
1694            )
1695            .map_err(|_| {
1696                NodeServerError::Registry(
1697                    "provider bundle argv release parent is unavailable".to_owned(),
1698                )
1699            })?;
1700            if proof_parent != release_parent {
1701                return Err(NodeServerError::Registry(
1702                    "provider bundle argv release path must share the proof parent".to_owned(),
1703                ));
1704            }
1705        }
1706        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1707            NodeServerError::Registry(
1708                "provider bundle argv proof path is not valid Unicode".to_owned(),
1709            )
1710        })?;
1711        let release_signal = release_signal.map(|release_signal| {
1712            release_signal.into_os_string().into_string().map_err(|_| {
1713                NodeServerError::Registry(
1714                    "provider bundle argv release path is not valid Unicode".to_owned(),
1715                )
1716            })
1717        }).transpose()?;
1718        let script = spec
1719            .launch
1720            .fixed_args
1721            .pop()
1722            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1723        let bundle_validation = if agent_id.as_str() == "codex" {
1724            "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') }"
1725        } else if agent_id.as_str() == "claude" {
1726            "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 }"
1727        } else {
1728            "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 }"
1729        };
1730        if let Some(release_signal) = release_signal {
1731            spec.launch.fixed_args.push(format!(
1732                "& {{ 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} }}",
1733            ));
1734            spec.launch.fixed_args.push(proof_path);
1735            spec.launch.fixed_args.push(release_signal);
1736        } else {
1737            spec.launch.fixed_args.push(format!(
1738                "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1739            ));
1740            spec.launch.fixed_args.push(proof_path);
1741        }
1742        Ok(spec)
1743    }
1744
1745    #[cfg(all(feature = "fixture", windows))]
1746    pub fn new_context_pack_fixture(
1747        config: NodeServerConfig,
1748        proof_path: PathBuf,
1749    ) -> Result<Self, NodeServerError> {
1750        let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1751        Self::new_with_registry(config, catalog)
1752    }
1753
1754    #[cfg(all(feature = "fixture", windows))]
1755    pub fn new_monitoring_context_pack_fixture(
1756        config: NodeServerConfig,
1757        proof_path: PathBuf,
1758    ) -> Result<Self, NodeServerError> {
1759        let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1760        let mut server = Self::new_with_registry(config, catalog)?;
1761        Arc::get_mut(&mut server.shared)
1762            .expect("fixture Node shared state must remain uniquely owned before run")
1763            .fixture_semantic_hook_policy = true;
1764        Ok(server)
1765    }
1766
1767    #[cfg(all(feature = "fixture", windows))]
1768    pub fn install_isolated_codex_home_environment_fixture(
1769        &mut self,
1770        profile_id: SpawnEnvironmentProfileId,
1771        profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1772    ) -> Result<(), NodeServerError> {
1773        let provider = AgentId::new("codex")
1774            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1775        let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1776            gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1777                "{}-pty",
1778                profile_id.as_str(),
1779            ))
1780            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1781            provider.clone(),
1782            TransportKind::Pty,
1783            vec![OsString::from("USERPROFILE")],
1784            Arc::new(IsolatedCodexHomeFixtureEnvironment),
1785        )
1786        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1787        let materialization = NodeSessionMaterializationProfile::new(
1788            Vec::new(),
1789            vec![crate::session_environment::NodeSessionPathBinding::new(
1790                "CODEX_HOME",
1791                crate::session_environment::NodeSessionPathClass::ProviderHome,
1792            )
1793            .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1794            Vec::new(),
1795        )
1796        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1797        self.install_environment_profile(
1798            NodeEnvironmentProfile::new_with_materialization(
1799                profile_id,
1800                profile_revision,
1801                provider,
1802                [native_profile],
1803                Some(materialization),
1804            )
1805            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1806        )
1807    }
1808
1809    #[cfg(all(feature = "fixture", windows))]
1810    pub fn new_context_only_proof_fixture(
1811        config: NodeServerConfig,
1812        target_provider: AgentId,
1813        proof_path: PathBuf,
1814    ) -> Result<Self, NodeServerError> {
1815        if target_provider.as_str() != "kimi" {
1816            return Err(NodeServerError::Registry(
1817                "context-only proof fixture requires Kimi".to_owned(),
1818            ));
1819        }
1820        let catalog = Self::context_pack_fixture_catalog(
1821            proof_path,
1822            Some(&target_provider),
1823            false,
1824        )?;
1825        Self::new_with_registry(config, catalog)
1826    }
1827
1828    #[cfg(all(feature = "fixture", windows))]
1829    fn context_pack_fixture_catalog(
1830        proof_path: PathBuf,
1831        context_only_target: Option<&AgentId>,
1832        monitoring_claude_source: bool,
1833    ) -> Result<AgentRegistry, NodeServerError> {
1834        if !proof_path.is_absolute()
1835            || !proof_path
1836                .parent()
1837                .is_some_and(|parent| parent.is_dir())
1838        {
1839            return Err(NodeServerError::Registry(
1840                "context pack proof path requires an existing absolute parent".to_owned(),
1841            ));
1842        }
1843        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1844            NodeServerError::Registry(
1845                "context pack proof path is not valid Unicode".to_owned(),
1846            )
1847        })?;
1848        let providers = ["claude", "codex", "grok", "kimi"];
1849        let builtins = builtin_registry();
1850        let mut specs = Vec::with_capacity(providers.len());
1851        for provider_id in providers {
1852            let agent_id = AgentId::new(provider_id)
1853                .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1854            let provider = builtins.get(&agent_id).ok_or_else(|| {
1855                NodeServerError::Registry(
1856                    "context pack fixture provider adapter is unavailable".to_owned(),
1857                )
1858            })?;
1859            let monitored_provider =
1860                monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1861            let mut spec = if monitored_provider {
1862                gate4agent_testkit::monitoring_hook_agent_spec()
1863            } else {
1864                gate4agent_testkit::interactive_agent_spec()
1865            };
1866            spec.id = agent_id.clone();
1867            spec.display_name = format!("Controlled {agent_id} context pack fixture");
1868            spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1869            spec.detection.aliases.clear();
1870            spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1871            if provider_id == "codex" {
1872                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1873                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1874                })?;
1875                let validation = r#"
1876$bundleArgs = @($bundleArgs)
1877if ($bundleArgs.Count -ne 0) { exit 91 }
1878$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1879$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1880$cwd = (Get-Location).ProviderPath
1881if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1882$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1883if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1884$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1885if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1886$sha = [Security.Cryptography.SHA256]::Create()
1887try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1888if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1889if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1890$contextPath = Join-Path $contextRoot 'context-pack.json'
1891if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1892$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1893if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1894$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1895$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1896$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1897$separator = [IO.Path]::DirectorySeparatorChar
1898if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1899if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1900$rawContext = [IO.File]::ReadAllText($contextPath)
1901if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1902try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1903$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1904$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1905foreach ($property in @($document.PSObject.Properties.Name)) {
1906    if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1907}
1908foreach ($property in $requiredTopLevel) {
1909    if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1910}
1911if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1912if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1913$messages = @($document.retained_messages)
1914if ($messages.Count -eq 0) { exit 107 }
1915foreach ($message in $messages) {
1916    $messageProperties = @($message.PSObject.Properties.Name)
1917    foreach ($property in $messageProperties) {
1918        if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1919    }
1920    if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1921}
1922$sha = [Security.Cryptography.SHA256]::Create()
1923try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1924[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))
1925$rawProof = [IO.File]::ReadAllText($proofPath)
1926if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1927[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1928"#;
1929                spec.launch.fixed_args.push(format!(
1930                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1931                ));
1932                spec.launch.fixed_args.push(proof_path.clone());
1933            } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1934                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1935                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1936                })?;
1937                let validation = r#"
1938$bundleArgs = @($bundleArgs)
1939if ($bundleArgs.Count -ne 0) { exit 91 }
1940$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1941$cwd = (Get-Location).ProviderPath
1942if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1943$contextPath = Join-Path $contextRoot 'context-pack.json'
1944if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1945$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1946if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1947$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1948$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1949$separator = [IO.Path]::DirectorySeparatorChar
1950if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1951try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1952if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1953if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1954$messages = @($document.retained_messages)
1955if ($messages.Count -eq 0) { exit 99 }
1956$hasUser = $false
1957$hasAssistant = $false
1958foreach ($message in $messages) {
1959    if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1960    if ([string]$message.role -ceq 'user') { $hasUser = $true }
1961    elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1962    else { exit 101 }
1963}
1964if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1965$sha = [Security.Cryptography.SHA256]::Create()
1966try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1967[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1968[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1969"#;
1970                spec.launch.fixed_args.push(format!(
1971                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1972                ));
1973                spec.launch.fixed_args.push(proof_path.clone());
1974            }
1975            specs.push(spec);
1976        }
1977        let catalog = AgentRegistry::new(specs)
1978            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1979        Ok(catalog)
1980    }
1981
1982    #[cfg(feature = "fixture")]
1983    pub fn new_exact_launcher_fixture(
1984        config: NodeServerConfig,
1985        agent_id: AgentId,
1986        launcher: String,
1987    ) -> Result<Self, NodeServerError> {
1988        if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1989            return Err(NodeServerError::Registry(
1990                "exact launcher fixture requires a supported provider".to_owned(),
1991            ));
1992        }
1993        if launcher.contains('\0') {
1994            return Err(NodeServerError::Registry(
1995                "exact launcher fixture path contains NUL".to_owned(),
1996            ));
1997        }
1998        let launcher_path = Path::new(&launcher);
1999        if !launcher_path.is_absolute() {
2000            return Err(NodeServerError::Registry(
2001                "exact launcher fixture path must be absolute".to_owned(),
2002            ));
2003        }
2004        let metadata = std::fs::metadata(launcher_path).map_err(|_| {
2005            NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
2006        })?;
2007        if !metadata.is_file() {
2008            return Err(NodeServerError::Registry(
2009                "exact launcher fixture path must be a regular file".to_owned(),
2010            ));
2011        }
2012        let mut spec = builtin_registry()
2013            .get(&agent_id)
2014            .ok_or_else(|| {
2015                NodeServerError::Registry(
2016                    "exact launcher fixture provider is unavailable".to_owned(),
2017                )
2018            })?
2019            .clone();
2020        spec.launch.program = launcher;
2021        Self::new_fixture_with_spec(config, spec)
2022    }
2023
2024    /// Shared H3B validation and spec construction for
2025    /// `new_harness_mcp_proxy_fixture` and
2026    /// `new_harness_mcp_acp_launcher_fixture`: an absolute, exact-regular-
2027    /// file provider launcher plus at most 32 Unicode, NUL-free fixed
2028    /// arguments (65,536 bytes total), replacing `spec.launch` on a clone of
2029    /// the named builtin catalog entry -- detection command and expected
2030    /// process matcher rebased on the launcher's own basename, exactly the
2031    /// same shape `new_harness_mcp_proxy_fixture` always built.
2032    #[cfg(feature = "fixture")]
2033    fn h3b_provider_spec_with_program_and_args(
2034        agent_id: AgentId,
2035        provider_program: PathBuf,
2036        fixed_args: Vec<OsString>,
2037    ) -> Result<AgentSpec, NodeServerError> {
2038        if !provider_program.is_absolute() || fixed_args.len() > 32 {
2039            return Err(NodeServerError::Registry(
2040                "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2041            ));
2042        }
2043        let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2044            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2045        })?;
2046        if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2047            return Err(NodeServerError::Registry(
2048                "H3B fixture provider must be an exact regular file".to_owned(),
2049            ));
2050        }
2051        let program = provider_program.into_os_string().into_string().map_err(|_| {
2052            NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2053        })?;
2054        let fixed_args = fixed_args.into_iter().map(|argument| {
2055            argument.into_string().map_err(|_| NodeServerError::Registry(
2056                "H3B fixture argument is not Unicode".to_owned(),
2057            ))
2058        }).collect::<Result<Vec<_>, _>>()?;
2059        let argument_bytes = fixed_args.iter()
2060            .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2061        if program.contains('\0')
2062            || fixed_args.iter().any(|argument| argument.contains('\0'))
2063            || !matches!(argument_bytes, Some(total) if total <= 65_536)
2064        {
2065            return Err(NodeServerError::Registry(
2066                "H3B fixture provider or arguments are invalid".to_owned(),
2067            ));
2068        }
2069        let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2070            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2071        })?.clone();
2072        let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2073            .ok_or_else(|| NodeServerError::Registry(
2074                "H3B fixture provider basename is unavailable".to_owned(),
2075            ))?.to_owned();
2076        spec.detection.command = process_name.clone();
2077        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2078            name: process_name,
2079        }];
2080        spec.launch.program = program;
2081        spec.launch.fixed_args = fixed_args;
2082        Ok(spec)
2083    }
2084
2085    #[cfg(feature = "fixture")]
2086    pub fn new_harness_mcp_proxy_fixture(
2087        config: NodeServerConfig,
2088        provider_program: PathBuf,
2089        fixed_args: Vec<OsString>,
2090        harness_mcp_program: PathBuf,
2091    ) -> Result<Self, NodeServerError> {
2092        let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2093        let agent_id = AgentId::new("codex")
2094            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2095        let mut spec =
2096            Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2097        spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2098        config.fixture_raw_pty_runtime = true;
2099        Self::new_fixture_with_spec(config, spec)
2100    }
2101
2102    /// The ACP-mode sibling of `new_harness_mcp_proxy_fixture`: the same
2103    /// `--harness-mcp-helper` wiring and the same absolute-program-plus-
2104    /// fixed-args launcher replacement as `new_exact_launcher_fixture`, but
2105    /// admitting `ProviderRuntimeRequirement::Acp` the honest way instead of
2106    /// hardcoding it. `new_harness_mcp_proxy_fixture` forces `config.
2107    /// fixture_raw_pty_runtime = true`, which tears out the live
2108    /// `ProviderRuntimeMonitor` entirely (`NodeServer::new_with_registry`)
2109    /// and makes `NodeShared::admit_provider_runtime`'s no-monitor branch
2110    /// hardcode `acp_transport = false` for every provider, refusing
2111    /// `ProviderRuntimeRequirement::Acp` unconditionally regardless of what
2112    /// the catalog declares. This constructor leaves `fixture_raw_pty_
2113    /// runtime` at its default `false`, so a real `ProviderRuntimeMonitor`
2114    /// is built from the (cloned, launcher-replaced) catalog spec, and
2115    /// `ProviderRuntimeMonitor::supports_acp_transport` reads the honest,
2116    /// unmodified catalog fact: every builtin provider this accepts
2117    /// (`claude`, `codex`, `grok`, `kimi`) already declares `capabilities.
2118    /// transports.acp` in `gate4agent-catalog::builtin`, so no capability is
2119    /// invented here -- this constructor only removes the fixture-only
2120    /// bypass standing in its way. `provider_runtime::policy_for_transport`'s
2121    /// `TransportKind::Acp` arm (applied unconditionally at spawn-prep time
2122    /// for every ACP session, fixture or not) is what actually grants the
2123    /// ACP-shaped `ProviderRuntimePolicy` downstream of that admission --
2124    /// this constructor never calls it directly, and does not need to.
2125    #[cfg(feature = "fixture")]
2126    pub fn new_harness_mcp_acp_launcher_fixture(
2127        config: NodeServerConfig,
2128        helper_program: PathBuf,
2129        provider: AgentId,
2130        program: PathBuf,
2131        args: Vec<OsString>,
2132    ) -> Result<Self, NodeServerError> {
2133        if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2134            return Err(NodeServerError::Registry(
2135                "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2136            ));
2137        }
2138        let config = config.with_harness_mcp_helper(helper_program)?;
2139        let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2140        let launch = spec.launch.clone();
2141        let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2142            NodeServerError::Registry(
2143                "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2144                    .to_owned(),
2145            )
2146        })?;
2147        // Routes the ACP spawn itself through the exact replaced launcher
2148        // (`AcpSession::spawn_with_launch` -> a bare `Command::new(&launch.
2149        // program)`, `gate4agent`'s `src/acp/spawn.rs`) instead of the
2150        // catalog's own vendor-npm resolution (`acp_command`, same file) --
2151        // without this the ACP transport ignores `spec.launch` entirely and
2152        // tries to spawn the real vendor CLI (e.g. `npx -y @agentclientprotocol/
2153        // codex-acp@1.10.0`), which this fixture must never reach.
2154        acp.launch_override = Some(launch);
2155        spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2156        Self::new_fixture_with_spec(config, spec)
2157    }
2158
2159    #[cfg(feature = "fixture")]
2160    pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2161        let mut spec = gate4agent_testkit::interactive_agent_spec();
2162        let claude_id = AgentId::new("claude")
2163            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2164        let claude = builtin_registry()
2165            .get(&claude_id)
2166            .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2167        spec.id = claude_id;
2168        spec.display_name = "Resumable Claude PTY fixture".to_owned();
2169        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2170            name: "claude".to_owned(),
2171        }];
2172        spec.capabilities.transports.pty_adapter = claude
2173            .capabilities
2174            .transports
2175            .pty_adapter
2176            .clone();
2177        spec.capabilities.adapters.resume = claude
2178            .capabilities
2179            .adapters
2180            .resume
2181            .clone();
2182        let script = spec
2183            .launch
2184            .fixed_args
2185            .pop()
2186            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2187        spec.launch.fixed_args.push(format!(
2188            "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2189        ));
2190        Self::new_fixture_with_spec(config, spec)
2191    }
2192
2193    #[cfg(feature = "fixture")]
2194    pub fn new_cmd_cwd_fixture(
2195        config: NodeServerConfig,
2196        command: String,
2197    ) -> Result<Self, NodeServerError> {
2198        let mut spec = gate4agent_testkit::interactive_agent_spec();
2199        spec.id = AgentId::new("claude")
2200            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2201        spec.display_name = "Controlled cmd cwd fixture".to_owned();
2202        spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2203        spec.launch.program = command;
2204        spec.launch.fixed_args.clear();
2205        Self::new_fixture_with_spec(config, spec)
2206    }
2207
2208    #[cfg(feature = "fixture")]
2209    fn new_fixture_with_spec(
2210        config: NodeServerConfig,
2211        spec: gate4agent_types::AgentSpec,
2212    ) -> Result<Self, NodeServerError> {
2213        let catalog = AgentRegistry::new([spec])
2214            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2215        Self::new_with_registry(config, catalog)
2216    }
2217
2218    fn new_with_registry(
2219        config: NodeServerConfig,
2220        catalog: AgentRegistry,
2221    ) -> Result<Self, NodeServerError> {
2222        for profile in config.spawn_profiles.iter() {
2223            if catalog.get(&profile.provider).is_none() {
2224                return Err(NodeServerError::Registry(
2225                    "spawn profile references an unavailable provider".to_owned(),
2226                ));
2227            }
2228        }
2229        let input_settle_timeout_ms = catalog
2230            .iter()
2231            .map(|spec| spec.readiness.timeout_ms)
2232            .max()
2233            .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2234            .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2235            .max(MUTATION_SETTLE_TIMEOUT_MS);
2236        let (provider_contracts, provider_adapter_contracts) =
2237            provider_contract_manifest(&catalog)?;
2238        let compatibility_support = node_compatibility_support_for_manifest(
2239            &provider_contracts,
2240            &provider_adapter_contracts,
2241        )?;
2242        validate_node_negotiated_handshake_capacity(&compatibility_support)
2243            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2244        let enabled_providers = provider_contracts
2245            .iter()
2246            .map(|contract| contract.provider.clone())
2247            .collect();
2248        #[cfg(feature = "fixture")]
2249        let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2250        #[cfg(not(feature = "fixture"))]
2251        let fixture_raw_pty_runtime = false;
2252        let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2253            let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2254                crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2255            })).expect("fixture provider catalog remains bounded");
2256            (None, statuses)
2257        } else {
2258            let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2259            let statuses = monitor.collect();
2260            (Some(monitor), statuses)
2261        };
2262        let state_path_lock =
2263            session_registry::StatePathLock::acquire(config.state_path.as_deref())
2264                .map_err(|error| {
2265                    durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2266                })?;
2267        let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2268            Some(state_path) => {
2269                let delivery_root = delivery_store_root_for_state_path(state_path)
2270                    .ok_or_else(|| {
2271                        NodeServerError::DeliveryStore(
2272                            "delivery store root could not be derived from state path".to_owned(),
2273                        )
2274                    })?;
2275                let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2276                    let detail = match &error {
2277                        DeliveryStoreError::Storage(io_error) => {
2278                            format!("{error}: {io_error}")
2279                        }
2280                        _ => error.to_string(),
2281                    };
2282                    NodeServerError::DeliveryStore(detail)
2283                })?;
2284                (Some(store), bundles)
2285            }
2286            None => (None, Vec::new()),
2287        };
2288        let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2289            NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2290        })?;
2291        let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2292            Some(state_path) => {
2293                let context_pack_root = context_pack_store_root_for_state_path(state_path)
2294                    .ok_or(NodeServerError::ContextPackStore)?;
2295                let (store, packs) = ContextPackStore::open(context_pack_root)
2296                    .map_err(|_| NodeServerError::ContextPackStore)?;
2297                (Some(store), packs)
2298            }
2299            None => (None, Vec::new()),
2300        };
2301        let mut durable_context_catalog = ContextPackCatalog::default();
2302        for pack in durable_context_packs {
2303            durable_context_catalog
2304                .insert(pack)
2305                .map_err(|_| NodeServerError::ContextPackStore)?;
2306        }
2307        let session_environment_materializer = config
2308            .session_environment
2309            .as_ref()
2310            .map(|environment| {
2311                SessionEnvironmentMaterializer::new(
2312                    environment.root.clone(),
2313                    Arc::clone(&environment.resolver),
2314                )
2315                .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2316            })
2317            .transpose()?;
2318        // Session launchers go through the unprivileged `exec-as` wrapper when run-as is set
2319        // (the provider version probe above already used the original catalog).
2320        #[cfg(unix)]
2321        let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2322        let native_session_catalog = config.history.clone().map(|history| {
2323            Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2324        });
2325        let (handle, runtime) = match config.history.clone() {
2326            Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2327            None => NativeRuntime::new(catalog, config.runtime),
2328        };
2329        let native_launch_profile_control = runtime.native_launch_profile_control();
2330        let harness_mcp_registry = config
2331            .harness_mcp_helper
2332            .clone()
2333            .map(HarnessMcpProxyRegistry::new);
2334        let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2335        let incarnation_id = random_incarnation_id()
2336            .map_err(NodeServerError::IncarnationIdentity)?;
2337        let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2338            .map_err(durable_state_load_error)?;
2339        let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2340        let managed_worktree_tombstones =
2341            std::mem::take(&mut loaded.managed_worktree_tombstones);
2342        let materializations = std::mem::take(&mut loaded.materializations);
2343        let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2344        let (workspaces, records, persistence_warning) =
2345            merge_durable_state(&config.workspaces, loaded)?;
2346        let mut shared = NodeShared::new_with_incarnation(
2347            handle,
2348            config.access_token.clone(),
2349            config.node_id.clone(),
2350            incarnation_id,
2351            workspaces,
2352            enabled_providers,
2353            provider_runtime_statuses,
2354            provider_runtime_monitor,
2355            provider_contracts,
2356            provider_adapter_contracts,
2357            config.spawn_profiles.clone(),
2358            Some(native_launch_profile_control),
2359            native_session_catalog,
2360            session_environment_materializer,
2361            config.state_path.clone(),
2362            records,
2363            managed_worktrees,
2364            managed_worktree_tombstones,
2365            materializations,
2366            persistence_warning,
2367            input_settle_timeout_ms,
2368        );
2369        shared.harness_mcp_registry = harness_mcp_registry.clone();
2370        shared.session_record_retention = config.session_record_retention;
2371        shared.network_allowlist_catalog =
2372            RwLock::new(config.network_allowlist_catalog.clone());
2373        shared.bundle_catalog = RwLock::new(delivered_catalog);
2374        shared.delivery_store = Mutex::new(delivery_store);
2375        shared.context_catalog = RwLock::new(durable_context_catalog);
2376        shared.context_pack_store = Mutex::new(context_pack_store);
2377        shared.managed_spawn_replays = Mutex::new(
2378            managed_spawn_replays
2379                .into_iter()
2380                .map(|record| (record.idempotency_key.clone(), record))
2381                .collect(),
2382        );
2383        let shared = Arc::new(shared);
2384        if let Some(registry) = harness_mcp_registry {
2385            let weak = Arc::downgrade(&shared);
2386            registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2387                Some(shared) => shared.publish_transient(event),
2388                None => false,
2389            }));
2390        }
2391        if shared.persistence_error().is_none() {
2392            shared.persist_state().map_err(|error| {
2393                durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2394            })?;
2395        }
2396        Ok(Self {
2397            config,
2398            runtime,
2399            shared,
2400            events,
2401            state_path_lock,
2402        })
2403    }
2404
2405    pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2406        NodeShutdownHandle {
2407            shared: Arc::clone(&self.shared),
2408        }
2409    }
2410
2411    #[cfg(feature = "fixture")]
2412    pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2413        &self,
2414    ) -> SpawnManagedWorktreeV2FailureProbe {
2415        self.shared
2416            .fixture_spawn_managed_worktree_v2_failure_probe
2417            .clone()
2418    }
2419
2420    /// Installs one immutable host-local environment profile before the server runs.
2421    ///
2422    /// Native resolvers remain inside the runtime; the shared node registry keeps
2423    /// only public IDs, revisions, provider bindings, and opaque native profile IDs.
2424    pub fn install_environment_profile(
2425        &mut self,
2426        profile: NodeEnvironmentProfile,
2427    ) -> Result<(), NodeServerError> {
2428        if !self.shared.enabled_providers.contains(profile.provider()) {
2429            return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2430                profile.provider().clone(),
2431            ));
2432        }
2433        let (binding, native_profiles, materialization) = profile.into_parts();
2434        if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2435            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2436        }
2437        let mut profiles = self
2438            .shared
2439            .environment_profiles
2440            .write()
2441            .unwrap_or_else(|poisoned| poisoned.into_inner());
2442        if profiles.contains_key(&binding.id) {
2443            return Err(NodeServerError::DuplicateEnvironmentProfile(
2444                binding.id.clone(),
2445            ));
2446        }
2447        if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2448            return Err(NodeServerError::EnvironmentProfileCapacity {
2449                max: MAX_NODE_ENVIRONMENT_PROFILES,
2450            });
2451        }
2452        for native_id in binding.native_profile_ids() {
2453            if profiles.values().any(|current| {
2454                current.native_profile_ids().any(|current_id| current_id == native_id)
2455            }) {
2456                return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2457                    native_id.to_string(),
2458                ));
2459            }
2460        }
2461
2462        let mut installed = Vec::with_capacity(native_profiles.len());
2463        for native_profile in native_profiles {
2464            let native_id = native_profile.id().clone();
2465            if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2466                for installed_id in installed.into_iter().rev() {
2467                    let _ = self.runtime.remove_native_launch_profile(&installed_id);
2468                }
2469                return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2470            }
2471            installed.push(native_id);
2472        }
2473        if let Some(materialization) = materialization {
2474            self.shared
2475                .environment_materialization_profiles
2476                .write()
2477                .unwrap_or_else(|poisoned| poisoned.into_inner())
2478                .insert(binding.id.clone(), materialization);
2479        }
2480        profiles.insert(binding.id.clone(), binding);
2481        Ok(())
2482    }
2483
2484    /// Installs one immutable host-local bundle before the server runs.
2485    pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2486        if self.shared.session_environment_materializer.is_none() {
2487            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2488        }
2489        let mut catalog = self
2490            .shared
2491            .bundle_catalog
2492            .write()
2493            .unwrap_or_else(|poisoned| poisoned.into_inner());
2494        catalog
2495            .insert_idempotent(bundle)
2496            .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2497        Ok(())
2498    }
2499
2500    pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2501        let shutdown = self.shutdown_handle();
2502        let run = self.run();
2503        tokio::pin!(run);
2504        tokio::select! {
2505            result = &mut run => result,
2506            signal = wait_for_ctrl_signal() => {
2507                signal?;
2508                let shutdown_result = shutdown.request_shutdown().await;
2509                let run_result = run.await;
2510                shutdown_result.and(run_result)
2511            }
2512        }
2513    }
2514
2515    pub async fn run(self) -> Result<(), NodeServerError> {
2516        let NodeServer {
2517            config,
2518            mut runtime,
2519            shared,
2520            events,
2521            state_path_lock: _state_path_lock,
2522        } = self;
2523        shared.reconcile_materializations();
2524        shared.reconcile_managed_worktrees().await;
2525        shared.retire_unavailable_session_records().await;
2526        shared.reconcile_context_pack_exports().await;
2527        {
2528            let restore = Arc::clone(&shared);
2529            tokio::spawn(async move {
2530                sleep(Duration::from_secs(3)).await;
2531                restore.restore_journalled_sessions().await;
2532                loop {
2533                    restore.refresh_session_journal();
2534                    if restore.shutdown.load(Ordering::Acquire) { break; }
2535                    sleep(Duration::from_secs(10)).await;
2536                }
2537            });
2538        }
2539        // Lifecycle hooks are retired (owner ruling 2026-09-25): the node
2540        // observes sessions through ACP where a provider has it; there is no
2541        // hook ingress.
2542        let endpoint = config.endpoint.clone();
2543        let call_home = config.call_home;
2544        let call_home_identity = config.call_home_identity.clone();
2545        let api_listen = config.api_listen;
2546        let bridge_listen = config.bridge_listen;
2547        let bridge_underlay = config.bridge_underlay;
2548        let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2549            .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2550        let accept_shared = Arc::clone(&shared);
2551        let api_shared = Arc::clone(&shared);
2552        let bridge_shared = Arc::clone(&shared);
2553        let shutdown_shared = Arc::clone(&shared);
2554        let shutdown_timeout = Duration::from_millis(
2555            config.runtime.provider_shutdown_timeout_ms.max(1),
2556        );
2557        let result = {
2558            let runtime_loop = drive_runtime_until_shutdown(
2559                &mut runtime,
2560                events,
2561                Arc::clone(&shared),
2562                shutdown_timeout,
2563            );
2564            let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2565            let api_loop = http_api::run(api_listen, api_shared);
2566            let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2567            tokio::pin!(runtime_loop);
2568            tokio::pin!(accept_loop);
2569            tokio::pin!(api_loop);
2570            tokio::pin!(bridge_loop);
2571            tokio::select! {
2572                runtime_result = &mut runtime_loop => {
2573                    let shutdown_result = shutdown_shared
2574                        .begin_shutdown()
2575                        .await
2576                        .map_err(NodeServerError::ShutdownDispatch);
2577                    let (accept_result, api_result, bridge_result) =
2578                        tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2579                    match runtime_result {
2580                        Err(error) => Err(error),
2581                        Ok(()) => shutdown_result
2582                            .and(accept_result)
2583                            .and(api_result.map_err(NodeServerError::HttpApi))
2584                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2585                    }
2586                }
2587                accept_result = &mut accept_loop => {
2588                    let shutdown_result = shutdown_shared
2589                        .begin_shutdown()
2590                        .await
2591                        .map_err(NodeServerError::ShutdownDispatch);
2592                    let (runtime_result, api_result, bridge_result) =
2593                        tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2594                    match accept_result {
2595                        Err(error) => Err(error),
2596                        Ok(()) => shutdown_result
2597                            .and(runtime_result)
2598                            .and(api_result.map_err(NodeServerError::HttpApi))
2599                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2600                    }
2601                }
2602                api_result = &mut api_loop => {
2603                    let shutdown_result = shutdown_shared
2604                        .begin_shutdown()
2605                        .await
2606                        .map_err(NodeServerError::ShutdownDispatch);
2607                    let (runtime_result, accept_result, bridge_result) =
2608                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2609                    match api_result {
2610                        Err(error) => Err(NodeServerError::HttpApi(error)),
2611                        Ok(()) => shutdown_result
2612                            .and(runtime_result)
2613                            .and(accept_result)
2614                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2615                    }
2616                }
2617                bridge_result = &mut bridge_loop => {
2618                    let shutdown_result = shutdown_shared
2619                        .begin_shutdown()
2620                        .await
2621                        .map_err(NodeServerError::ShutdownDispatch);
2622                    let (runtime_result, accept_result, api_result) =
2623                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2624                    match bridge_result {
2625                        Err(error) => Err(NodeServerError::BridgeApi(error)),
2626                        Ok(()) => shutdown_result
2627                            .and(runtime_result)
2628                            .and(accept_result)
2629                            .and(api_result.map_err(NodeServerError::HttpApi)),
2630                    }
2631                }
2632            }
2633        };
2634        // Persistence gap fix: durable DeliveryStore / ContextPackStore hold
2635        // exclusive flock (Unix) / share-mode locks (Windows) inside NodeShared.
2636        // NodeShutdownHandle also clones that Arc, so without an explicit release
2637        // here a supervisor that keeps the shutdown handle cannot reopen the same
2638        // state path after run() returns (EAGAIN / Resource temporarily unavailable).
2639        // StatePathLock already drops with this stack frame (`_state_path_lock`).
2640        if let Ok(mut guard) = shared.delivery_store.lock() {
2641            *guard = None;
2642        }
2643        if let Ok(mut guard) = shared.context_pack_store.lock() {
2644            *guard = None;
2645        }
2646        result
2647    }
2648}
2649
2650async fn drive_runtime_until_shutdown(
2651    runtime: &mut NativeRuntime,
2652    events: EventSubscription,
2653    shared: Arc<NodeShared>,
2654    shutdown_timeout: Duration,
2655) -> Result<(), NodeServerError> {
2656    // Published once, never per iteration. The worker loops inside
2657    // `NativeRuntime` fold into this profile continuously; turning it into
2658    // percentiles is what costs, and that is `GET /metrics`'s bill to pay.
2659    let _ = shared
2660        .shell_efficiency_profile
2661        .set(runtime.shell_efficiency_profile());
2662    let mut shutdown_started = None;
2663    let mut last_retention_sweep = Instant::now();
2664    let mut last_provisional_reap = Instant::now();
2665    loop {
2666        // Every phase below is timed unconditionally -- see
2667        // `drive_loop_profile`'s own doc comment for why an always-on,
2668        // allocation-free ring is cheap enough to never need a feature
2669        // flag. This is the only place these numbers can be taken: the
2670        // phases are calls made directly from this loop, not something a
2671        // callee could time on our behalf.
2672        let runtime_tick_start = Instant::now();
2673        let tick_result = runtime.tick().await;
2674        let runtime_tick_elapsed = runtime_tick_start.elapsed();
2675
2676        let event_drain_start = Instant::now();
2677        // Every command the kernel refused, named. `NativeRuntimeTick`
2678        // has carried these outcomes all along and nothing read them, so a
2679        // rejected command -- a hook event denied by runtime policy, a
2680        // stale generation, an unsupported capability -- vanished at the
2681        // last step of the loop that produced it. A refusal the operator
2682        // cannot see is indistinguishable from an event that never arrived,
2683        // and telling those two apart is most of debugging this stack.
2684        for outcome in &tick_result.command_outcomes {
2685            if let Err(error) = &outcome.result {
2686                shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2687                tracing::warn!(
2688                    command_id = outcome.command_id.0,
2689                    cause = %error,
2690                    "control command rejected"
2691                );
2692                shared.record_rejected_command(
2693                    outcome.command_id,
2694                    error.to_string(),
2695                    error.is_unsupported_transport(),
2696                );
2697            }
2698        }
2699        let mut events_drained: u64 = 0;
2700        while let Ok(event) = events.try_recv() {
2701            events_drained += 1;
2702            let clean_exit = matches!(
2703                event.event,
2704                ControlEventKind::Exited { exit_code: Some(0), forced: false },
2705            );
2706            let instance_id = event.instance_id;
2707            let generation = event.generation;
2708            // Resolve and spawn before publish_control: publish_control's own
2709            // managed-record bookkeeping (reconcile_managed_record) downgrades
2710            // this exact record from Live to Dormant and clears
2711            // active_session for every Exited event, inside the same
2712            // synchronous call. Capturing (record_id, session) here — before
2713            // that runs — is the only way to observe them still Live and
2714            // still populated; the export itself must still run on its own
2715            // detached task (never awaited inline on this loop) because it
2716            // needs NativeRuntime::tick() to keep running to ever settle, and
2717            // tick() is only ever driven by this same loop. See
2718            // reconcile_context_pack_export_for_record's doc comment.
2719            if clean_exit {
2720                if let Some((record_id, session)) =
2721                    shared.managed_record_export_target_for_instance(instance_id, generation)
2722                {
2723                    let export_shared = Arc::clone(&shared);
2724                    tokio::spawn(async move {
2725                        export_shared
2726                            .reconcile_context_pack_export_for_record(&record_id, session)
2727                            .await;
2728                    });
2729                }
2730            }
2731            shared.publish_control(event);
2732        }
2733        let event_drain_elapsed = event_drain_start.elapsed();
2734
2735        let publish_terminal_frames_start = Instant::now();
2736        shared.publish_terminal_frames();
2737        let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2738
2739        // Physical PTY count and the runtime's own per-phase tick timings
2740        // both live inside `NativeRuntime`, which `GET /metrics` (reading
2741        // only `NodeShared`) cannot reach directly -- this loop is the one
2742        // place holding `&mut NativeRuntime`, so it is the only place that
2743        // can copy either out.
2744        shared
2745            .native_session_gauge
2746            .store(runtime.active_native_sessions(), Ordering::Relaxed);
2747        *shared
2748            .runtime_tick_profile
2749            .lock()
2750            .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2751
2752        let remainder_start = Instant::now();
2753        if shared.shutdown.load(Ordering::Acquire) {
2754            let started = *shutdown_started.get_or_insert_with(Instant::now);
2755            let snapshot = shared.handle.snapshot();
2756            let sessions_settled = snapshot.sessions.iter().all(|session| {
2757                !matches!(
2758                    session.status,
2759                    gate4agent_types::SessionStatus::Starting
2760                        | gate4agent_types::SessionStatus::Running
2761                        | gate4agent_types::SessionStatus::Stopping
2762                )
2763            });
2764            if runtime.active_native_sessions() == 0 && sessions_settled {
2765                return Ok(());
2766            }
2767            if started.elapsed() >= shutdown_timeout {
2768                return Err(NodeServerError::ShutdownTimedOut {
2769                    active_native_sessions: runtime.active_native_sessions(),
2770                });
2771            }
2772        }
2773        let remainder_elapsed = remainder_start.elapsed();
2774
2775        {
2776            let mut profile = shared
2777                .drive_loop_profile
2778                .lock()
2779                .unwrap_or_else(|poisoned| poisoned.into_inner());
2780            profile.record_runtime_tick(runtime_tick_elapsed);
2781            profile.record_event_drain(event_drain_elapsed);
2782            profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2783            profile.record_remainder(remainder_elapsed);
2784            profile.note_iteration(Instant::now());
2785        }
2786
2787        // "Produced nothing" means this iteration's `NativeRuntime::tick`
2788        // applied zero observations (`tick_result.observations_applied`,
2789        // which already folds in any terminal frames collected -- see
2790        // `NativeEffectDispatcher::drain_observations`) AND the
2791        // control-event subscription drained above forwarded zero events.
2792        // Command ingress and dispatched effects are deliberately NOT part
2793        // of this definition: an idle node still runs `step_control_plane`
2794        // and every provider supervisor's `tick` regardless, every 10ms --
2795        // so "nothing" here means nothing OBSERVABLE left this iteration,
2796        // matching what a reader would assume from `drive_loop_iterations`'s
2797        // own field name, not that the tick itself was free of work.
2798        shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2799        if tick_result.observations_applied == 0 && events_drained == 0 {
2800            shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2801        }
2802
2803        // A sweep cadence, not a policy value -- see
2804        // `SESSION_RECORD_RETENTION_SWEEP_PERIOD`'s own doc comment. The
2805        // policy itself (whether this does anything at all) lives entirely
2806        // in `NodeShared::session_record_retention`.
2807        if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2808            shared.retire_unavailable_session_records().await;
2809            last_retention_sweep = Instant::now();
2810        }
2811        // Provisional Spawn grace: kill PTYs that never got an HQ attach.
2812        if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2813            for address in shared.take_due_provisional_spawns() {
2814                shared.reap_provisional_spawn(address).await;
2815            }
2816            last_provisional_reap = Instant::now();
2817        }
2818
2819        // Fast-poll for 300ms after PTY input so its echo is read and published
2820        // within ~1ms; otherwise nap 10ms but wake at once on new input.
2821        let hot = shared
2822            .pty_input_marks
2823            .lock()
2824            .unwrap_or_else(|poisoned| poisoned.into_inner())
2825            .values()
2826            .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2827        if hot {
2828            sleep(Duration::from_millis(1)).await;
2829        } else {
2830            tokio::select! {
2831                _ = sleep(Duration::from_millis(10)) => {}
2832                _ = shared.pty_input_wake.notified() => {}
2833            }
2834        }
2835    }
2836}
2837
2838#[derive(Clone)]
2839pub struct NodeShutdownHandle {
2840    shared: Arc<NodeShared>,
2841}
2842
2843impl NodeShutdownHandle {
2844    pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2845        self.shared
2846            .begin_shutdown()
2847            .await
2848            .map_err(NodeServerError::ShutdownDispatch)
2849    }
2850}
2851
2852async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2853    #[cfg(windows)]
2854    {
2855    let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2856    let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2857    return tokio::select! {
2858        signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2859        signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2860    };
2861    }
2862    #[cfg(unix)]
2863    {
2864        let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2865        let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2866        tokio::select! {
2867            signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2868            signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2869        }
2870    }
2871}
2872
2873/// The providers this node actually runs.
2874///
2875/// `builtin_registry()` is a reference catalog of everything the project has
2876/// ever described; the fleet is this list, and it is deliberately short. A
2877/// provider only belongs here if it can be exercised for real -- there is a
2878/// subscription behind it and a failure in it can be reproduced and
2879/// debugged. Carrying one that cannot is carrying code nobody can verify,
2880/// which is worse than not supporting it: it looks supported.
2881///
2882/// A provider dropped from the fleet by this rule is removed outright, not
2883/// carried dark: no catalog entry, no adapter, no dead code claiming support
2884/// for something nobody can verify runs.
2885/// Host interactive shell for HQ terminal attach (no provider CLI, no network).
2886fn shell_host_spec() -> AgentSpec {
2887    // Windows nodes (incl. MSYS-hosted) have no `/bin/bash` for Win32 Path::is_file;
2888    // fleet inventory on op-box resolves `bash` to the WSL stub at
2889    // `/c/Windows/system32/bash`, which is not a host shell. Use ComSpec/cmd.
2890    #[cfg(windows)]
2891    let (detect_command, program, fixed_args, process_name) = {
2892        let program = std::env::var("ComSpec").unwrap_or_else(|_| {
2893            r"C:\Windows\System32\cmd.exe".to_owned()
2894        });
2895        ("cmd".to_owned(), program, Vec::<String>::new(), "cmd".to_owned())
2896    };
2897    #[cfg(not(windows))]
2898    let (detect_command, program, fixed_args, process_name) = (
2899        "bash".to_owned(),
2900        "/bin/bash".to_owned(),
2901        vec!["-i".to_owned()],
2902        "bash".to_owned(),
2903    );
2904    AgentSpec {
2905        id: AgentId::new("shell").expect("shell agent id"),
2906        revision: "hatchery:shell-pty-1".to_owned(),
2907        display_name: "Host shell".to_owned(),
2908        detection: DetectionSpec {
2909            command: detect_command,
2910            aliases: vec!["sh".to_owned(), "shell".to_owned(), "cmd".to_owned()],
2911            required_commands: Vec::new(),
2912            unsupported_platforms: Vec::new(),
2913        },
2914        launch: LaunchSpec {
2915            program,
2916            fixed_args,
2917        },
2918        expected_processes: vec![ProcessMatcher::Exact {
2919            name: process_name,
2920        }],
2921        prompt: PromptSpec {
2922            initial: InitialPromptMode::None,
2923            native_draft: None,
2924        },
2925        readiness: AgentReadinessSpec::default(),
2926        capabilities: AgentCapabilities {
2927            agent_commands: None,
2928            transports: AgentTransportCapabilities {
2929                pty: true,
2930                pty_adapter: None,
2931                pipe: None,
2932                acp: None,
2933            },
2934            adapters: AgentAdapterCapabilities::default(),
2935        },
2936        verification: SpecVerification::Gate4AgentVerified,
2937    }
2938}
2939
2940fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2941    let mut specs = vec![shell_host_spec()];
2942    specs.extend(
2943        builtin_registry()
2944            .iter()
2945            .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2946            .cloned(),
2947    );
2948    AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2949}
2950
2951fn provider_contract_manifest(
2952    catalog: &AgentRegistry,
2953) -> Result<
2954    (
2955        Vec<ProviderContractSupport>,
2956        Vec<ProviderAdapterContractSupport>,
2957    ),
2958    NodeServerError,
2959> {
2960    let mut provider_contracts = Vec::new();
2961    let mut provider_adapter_contracts = Vec::new();
2962    for spec in catalog.iter() {
2963        let provider = spec.id.clone();
2964        let revision = ProviderContractRevision::new(spec.revision.clone())
2965            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2966        provider_contracts.push(ProviderContractSupport {
2967            provider: provider.clone(),
2968            revision,
2969        });
2970        for (family, binding) in declared_adapter_bindings(spec)? {
2971            let revision = AdapterContractRevision::new(binding.revision.clone())
2972                .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2973            provider_adapter_contracts.push(ProviderAdapterContractSupport {
2974                provider: provider.clone(),
2975                family,
2976                adapter_id: binding.id.clone(),
2977                revision,
2978            });
2979        }
2980    }
2981    validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2982        .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2983    Ok((provider_contracts, provider_adapter_contracts))
2984}
2985
2986fn declared_adapter_bindings(
2987    spec: &AgentSpec,
2988) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2989    let mut bindings = Vec::new();
2990    push_declared_binding(
2991        &mut bindings,
2992        AdapterFamily::PtySemantic,
2993        spec.capabilities.transports.pty_adapter.as_ref(),
2994    )?;
2995    push_declared_binding(
2996        &mut bindings,
2997        AdapterFamily::Pipe,
2998        spec.capabilities.adapters.pty_sidecar.as_ref(),
2999    )?;
3000    let pipe_transport = spec.capabilities.transports.pipe.as_ref();
3001    let pipe_family = pipe_transport.map(|transport| match transport.protocol {
3002        gate4agent_types::PipeProtocol::SemanticNdjson
3003        | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
3004        gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
3005    });
3006    push_declared_binding(
3007        &mut bindings,
3008        AdapterFamily::Pipe,
3009        pipe_transport
3010            .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
3011            .map(|transport| &transport.adapter),
3012    )?;
3013    push_declared_binding(
3014        &mut bindings,
3015        AdapterFamily::Acp,
3016        spec.capabilities
3017            .transports
3018            .acp
3019            .as_ref()
3020            .map(|transport| &transport.adapter),
3021    )?;
3022    push_declared_binding(
3023        &mut bindings,
3024        AdapterFamily::Hook,
3025        spec.capabilities.adapters.hook.as_ref(),
3026    )?;
3027    push_declared_binding(
3028        &mut bindings,
3029        AdapterFamily::ManagedHook,
3030        spec.capabilities.adapters.managed_hook.as_ref(),
3031    )?;
3032    push_declared_binding(
3033        &mut bindings,
3034        AdapterFamily::OneShot,
3035        pipe_transport
3036            .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
3037            .map(|transport| &transport.adapter),
3038    )?;
3039    push_declared_binding(
3040        &mut bindings,
3041        AdapterFamily::OneShot,
3042        spec.capabilities.adapters.one_shot.as_ref(),
3043    )?;
3044    push_declared_binding(
3045        &mut bindings,
3046        AdapterFamily::History,
3047        spec.capabilities.adapters.history.as_ref(),
3048    )?;
3049    push_declared_binding(
3050        &mut bindings,
3051        AdapterFamily::Resume,
3052        spec.capabilities.adapters.resume.as_ref(),
3053    )?;
3054    push_declared_binding(
3055        &mut bindings,
3056        AdapterFamily::SessionOptions,
3057        spec.capabilities.adapters.session_options.as_ref(),
3058    )?;
3059    push_declared_binding(
3060        &mut bindings,
3061        AdapterFamily::CapabilityProbe,
3062        spec.capabilities.adapters.capability_probe.as_ref(),
3063    )?;
3064    Ok(bindings)
3065}
3066
3067fn push_declared_binding<'a>(
3068    bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3069    family: AdapterFamily,
3070    binding: Option<&'a AdapterBinding>,
3071) -> Result<(), NodeServerError> {
3072    let Some(binding) = binding else {
3073        return Ok(());
3074    };
3075    if let Some((_, existing)) = bindings
3076        .iter()
3077        .find(|(existing_family, _)| *existing_family == family)
3078    {
3079        if *existing == binding {
3080            return Ok(());
3081        }
3082        return Err(NodeServerError::ProviderContractManifest(format!(
3083            "provider declares conflicting {family:?} adapter bindings"
3084        )));
3085    }
3086    bindings.push((family, binding));
3087    Ok(())
3088}
3089
3090fn merge_durable_state(
3091    configured: &[WorkspaceConfig],
3092    mut loaded: LoadedNodeState,
3093) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3094    let mut workspaces = configured
3095        .iter()
3096        .cloned()
3097        .map(|workspace| (workspace.workspace_id.clone(), workspace))
3098        .collect::<BTreeMap<_, _>>();
3099    let mut roots = workspaces
3100        .values()
3101        .map(|workspace| {
3102            (
3103                platform::root_identity(&workspace.canonical_root),
3104                workspace.workspace_id.clone(),
3105            )
3106        })
3107        .collect::<BTreeMap<_, _>>();
3108    let mut warning = loaded
3109        .warning
3110        .take()
3111        .map(|message| session_registry::sanitized_persistence_summary(&message));
3112
3113    for (workspace_id, canonical_root) in loaded.workspaces {
3114        if let Some(existing) = workspaces.get(&workspace_id) {
3115            if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3116                return Err(NodeServerError::DurableState(io::Error::new(
3117                    io::ErrorKind::InvalidData,
3118                    DURABLE_STATE_CONFLICT_ERROR,
3119                )));
3120            }
3121            continue;
3122        }
3123        if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3124            return Err(NodeServerError::DurableState(io::Error::new(
3125                io::ErrorKind::InvalidData,
3126                DURABLE_STATE_CONFLICT_ERROR,
3127            )));
3128        }
3129        if !Path::new(&canonical_root).is_dir() {
3130            warning.get_or_insert_with(|| {
3131                session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3132            });
3133            continue;
3134        }
3135        let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3136            NodeServerError::DurableState(io::Error::new(
3137                io::ErrorKind::InvalidData,
3138                DURABLE_STATE_CONFLICT_ERROR,
3139            ))
3140        })?;
3141        roots.insert(
3142            platform::root_identity(&workspace.canonical_root),
3143            workspace.workspace_id.clone(),
3144        );
3145        workspaces.insert(workspace_id, workspace);
3146    }
3147
3148    for record in &mut loaded.records {
3149        match workspaces.get(&record.workspace_id) {
3150            Some(workspace)
3151                if platform::roots_equal(
3152                    &workspace.canonical_root,
3153                    windows_path_text(&record.canonical_root),
3154                ) => {}
3155            Some(_) => {
3156                return Err(NodeServerError::DurableState(io::Error::new(
3157                    io::ErrorKind::InvalidData,
3158                    DURABLE_STATE_CONFLICT_ERROR,
3159                )));
3160            }
3161            None => {
3162                record.state = ManagedSessionState::Unavailable;
3163                record.active_session = None;
3164                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3165            }
3166        }
3167    }
3168    Ok((workspaces.into_values().collect(), loaded.records, warning))
3169}
3170
3171/// One entry of `NodeShared::rejected_command_reasons` -- see that field's
3172/// own doc for the mechanism this exists to close.
3173struct RejectedCommandReason {
3174    message: String,
3175    /// True when the kernel refused this command specifically because the
3176    /// agent does not declare the requested transport (`KernelCommandError::
3177    /// UnsupportedTransport`) -- lets the reader choose the named
3178    /// `NodeFailureCode::UnsupportedTransport` over the generic
3179    /// `BackendOperationFailed` bucket, so this one well-known rejection
3180    /// keeps its identity all the way to the operator wire.
3181    unsupported_transport: bool,
3182    expires_at: Instant,
3183}
3184
3185struct NodeShared {
3186    handle: Gate4AgentHandle,
3187    access_token: String,
3188    node_id: NodeId,
3189    incarnation_id: NodeIncarnationId,
3190    started_at_unix_ms: u64,
3191    workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3192    worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3193    managed_worktree_profiles:
3194        BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3195    managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3196    enabled_providers: Vec<AgentId>,
3197    provider_runtime_statuses: ProviderRuntimeStatuses,
3198    provider_runtime_status_updates:
3199        RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3200    provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3201    provider_contracts: Vec<ProviderContractSupport>,
3202    provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3203    spawn_profiles: SpawnProfileRegistry,
3204    environment_profiles:
3205        RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3206    environment_materialization_profiles:
3207        RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3208    /// Station **network** allowlist catalog (opaque policy ids only).
3209    /// Empty by default — any `network_allowlist` on spawn must be registered
3210    /// here or resolve refuses with `UnknownNetworkAllowlist`. Optional
3211    /// startup load via `NodeServerConfig::with_network_allowlist_catalog(_file)`
3212    /// / env `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG`. Plan
3213    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 / §4.
3214    /// Never holds cookies / OAuth / proxy credentials.
3215    network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3216    /// Node-local exclusive dig2browser station profile leases (feature
3217    /// `dig2-station-probe`). Opaque `browser_profile_id` ↔ session holder;
3218    /// cleared on session end. Never ImportSession / cookies on C2.
3219    #[cfg(feature = "dig2-station-probe")]
3220    browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3221    bundle_catalog: RwLock<BundleCatalog>,
3222    delivery_store: Mutex<Option<DeliveryStore>>,
3223    context_pack_store: Mutex<Option<ContextPackStore>>,
3224    context_catalog: RwLock<ContextPackCatalog>,
3225    native_launch_profile_control: Option<NativeLaunchProfileControl>,
3226    harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3227    native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3228    session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3229    /// Per-session m4a accounts assigned at spawn (see [`crate::m4a_accounts`]).
3230    m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3231    /// nick -> the session that holds that m4a account (for letter wakes).
3232    m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3233    /// m4a session key per runtime this node gave an account.
3234    m4a_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3235    /// Keys a resume hands to its new runtime (same nick and mailbox).
3236    m4a_reuse_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3237    /// Per-session grok leader sockets (never the operator's shared leader).
3238    grok_leaders: Mutex<BTreeMap<AgentInstanceId, std::path::PathBuf>>,
3239    /// m4a signer proxy (option A): when set, PTY sessions get a process-bound handle
3240    /// instead of a bearer capability.
3241    #[cfg(unix)]
3242    signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3243    materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3244    spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3245    managed_spawn_replays:
3246        Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3247    controller: Mutex<Option<ControllerLease>>,
3248    /// Fresh spawns waiting for their first successful HQ attach. Removed on
3249    /// Resize/TerminalBytes (sessionize) or when the grace reaper stops them.
3250    provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3251    /// ACP launch options waiting for the agent to advertise its config options.
3252    pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3253    history: Mutex<NodeEventHistory>,
3254    event_tx: broadcast::Sender<NodeEventEnvelope>,
3255    terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3256    /// The outbound `NodeEvent::AgentStream` push channel -- its own
3257    /// dedicated, capability-gated broadcast, mirroring `terminal_event_tx`
3258    /// exactly rather than riding the durable `event_tx` history (see
3259    /// `publish`'s own assertion): a subscriber who never negotiated
3260    /// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` must never receive one of
3261    /// these, the same guarantee `include_terminal_frame_events` already
3262    /// gives terminal frames.
3263    agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3264    /// The outbound `NodeEvent::HarnessMcpReadCall` push channel -- mirrors
3265    /// `agent_stream_event_tx` exactly, its own dedicated, capability-gated
3266    /// broadcast rather than a ride on the durable `event_tx` history. A
3267    /// `HarnessMcpReadCall` is a request/response call a live proxy call is
3268    /// waiting on (`harness_mcp_proxy.rs`'s `begin_call`); the shared bus's
3269    /// finite capacity and its willingness to silently evict a lagging
3270    /// subscriber's backlog is exactly how the call vanished with neither
3271    /// side ever logging it (see `publish_transient`'s own doc).
3272    harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3273    terminal_frame_watermarks:
3274        Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3275    /// Wakes the drive loop as soon as PTY input is queued (no 10ms nap).
3276    pty_input_wake: tokio::sync::Notify,
3277    /// Per session: when the last PTY input was dispatched (latency probe +
3278    /// a short fast-poll window so the echo is published within ~1ms).
3279    pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3280    next_connection_id: AtomicU64,
3281    next_instance_id: AtomicU64,
3282    next_command_id: AtomicU64,
3283    shutdown: AtomicBool,
3284    shutdown_notify: Notify,
3285    preauth_slots: Arc<Semaphore>,
3286    authenticated_slots: Arc<Semaphore>,
3287    inspection_slots: Arc<Semaphore>,
3288    mutation_gate: AsyncMutex<()>,
3289    session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3290    session_records: Mutex<SessionRecords>,
3291    state_path: Option<PathBuf>,
3292    persistence_error: RwLock<Option<String>>,
3293    state_transaction: Mutex<()>,
3294    input_settle_timeout_ms: u64,
3295    /// Drive-loop phase/rate timings -- see `drive_loop_profile`'s own doc
3296    /// comment. Written once per iteration by
3297    /// `drive_runtime_until_shutdown`, read by `GET /metrics`.
3298    drive_loop_profile: Mutex<DriveLoopProfiler>,
3299    /// Latest copy of `NativeRuntime::tick_profile_snapshot()` -- `Copy`,
3300    /// so an overwrite each iteration is cheap. The runtime itself is not
3301    /// reachable from here (it is owned locally by
3302    /// `drive_runtime_until_shutdown`), so this is how its own per-phase
3303    /// tick timings reach `GET /metrics`.
3304    runtime_tick_profile: Mutex<TickProfileSnapshot>,
3305    /// The live shell-efficiency profile, shared with the per-instance
3306    /// worker loops that fold into it.
3307    ///
3308    /// Deliberately NOT the per-iteration snapshot copy `runtime_tick_
3309    /// profile` above keeps: producing a snapshot sorts every ring behind
3310    /// it, and that sort belongs on the read. A `OnceLock` rather than a
3311    /// field set at construction because `NodeShared` is built before the
3312    /// `NativeRuntime` that owns the profile exists -- the drive loop
3313    /// publishes it once, before its first iteration. Unset means the
3314    /// runtime has not started driving yet, which `GET /metrics` reports as
3315    /// empty series rather than as zeroes it never measured.
3316    shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3317    /// Physical PTY sessions the native runtime currently owns
3318    /// (`NativeRuntime::active_native_sessions`), refreshed once per drive
3319    /// loop iteration -- deliberately a separate count from
3320    /// `handle.snapshot().sessions.len()` (the control-plane kernel's own
3321    /// session bookkeeping), since the two can disagree and the gap is
3322    /// itself a diagnostic signal `GET /metrics` exposes.
3323    native_session_gauge: AtomicUsize,
3324    /// Lifetime totals for the per-connection serve loop, summed across
3325    /// every live connection.
3326    ///
3327    /// The drive loop's own phase timings account for about ten
3328    /// microseconds an iteration on an idle node, yet the process burns a
3329    /// quarter of a core — and burns it ONLY while a c2 is attached (0.5%
3330    /// with none, 23% with two, measured). That gap is somewhere in
3331    /// serving a connection, and there was no way to see it: iterations
3332    /// says whether the loop is spinning rather than sleeping, and events
3333    /// says whether it is spinning over real work or over nothing.
3334    connection_loop_iterations: AtomicU64,
3335    connection_events_sent: AtomicU64,
3336    /// Lifetime totals for `drive_runtime_until_shutdown`'s own iterations
3337    /// -- `drive_loop_iterations_idle` counts iterations that applied zero
3338    /// `NativeRuntime::tick` observations AND forwarded zero control
3339    /// events; see the exact "produced nothing" definition at the site in
3340    /// `drive_runtime_until_shutdown` that increments these. Read twice
3341    /// and divide by the interval, same rule as `connections` above --
3342    /// `idle / total` is backlog item 5's "wake on work" number.
3343    /// Lifetime count of control commands the kernel refused.
3344    rejected_commands_total: AtomicU64,
3345    /// The named reason behind a recent kernel rejection, by the
3346    /// `CommandId` `dispatch_bounded` handed back when it enqueued that
3347    /// command -- populated by `drive_runtime_until_shutdown` for every
3348    /// `Err` in a tick's `command_outcomes`, and drained by whichever
3349    /// spawn-dispatch waiter recognizes its own `Register`/`Start`
3350    /// `CommandId` among them.
3351    ///
3352    /// A `ControlCommand` only ever fails synchronously (a `NodeFailure`
3353    /// out of `dispatch`/`dispatch_bounded` itself) for a port-level ingress
3354    /// problem -- full or disconnected -- never for the kernel refusing the
3355    /// command's content (an unsupported transport, an unknown agent, ...);
3356    /// that verdict exists only once `NativeRuntime::tick` has processed the
3357    /// command, one drive-loop iteration later, in a different task than
3358    /// whichever caller is awaiting the spawn. Without this map, a spawn
3359    /// wait loop that polls `self.handle.snapshot()` for a session that
3360    /// will now never appear has no way to learn why -- it can only spin
3361    /// until its own commit deadline and report a generic "did not commit"
3362    /// timeout, discarding a reason the kernel already named precisely (see
3363    /// `KernelCommandError`'s variants, e.g. `UnsupportedTransport`).
3364    rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3365    drive_loop_iterations_total: AtomicU64,
3366    drive_loop_iterations_idle: AtomicU64,
3367    /// The operator-chosen age/keep-N policy the retention sweep applies to
3368    /// `ManagedSessionState::Unavailable` records. Both `0` by default --
3369    /// see `SessionRecordRetentionConfig`'s own doc comment.
3370    session_record_retention: SessionRecordRetentionConfig,
3371    /// Lifetime count of managed session records the retention sweep has
3372    /// retired. Same tier as `rejected_commands_total` above: a live,
3373    /// process-lifetime instrument, never persisted and never reloaded from
3374    /// `state-v1.json` -- a restart legitimately resets it, matching every
3375    /// other `*_total` counter here.
3376    retired_records_total: AtomicU64,
3377    #[cfg(feature = "fixture")]
3378    fixture_semantic_hook_policy: bool,
3379    #[cfg(feature = "fixture")]
3380    fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3381}
3382
3383impl Drop for NodeShared {
3384    fn drop(&mut self) {
3385        SessionLifecycleHook::on_shutdown(self);
3386    }
3387}
3388
3389#[derive(Clone, Debug, Eq, PartialEq)]
3390struct SessionBinding {
3391    workspace_id: WorkspaceId,
3392    generation: SessionGeneration,
3393    runtime_policy: ProviderRuntimePolicy,
3394    pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3395    record_id: Option<SessionRecordId>,
3396    managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3397    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3398    bundle: Option<ResolvedBundleReceipt>,
3399    context: Option<ResolvedContextPackReceipt>,
3400    materialization_id: Option<MaterializationId>,
3401}
3402
3403#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3404enum SpawnRecordPolicy {
3405    ProviderIdentityOnly,
3406    Always,
3407}
3408
3409struct NativeEnvironmentSelectionGuard {
3410    control: Option<NativeLaunchProfileControl>,
3411    instance_id: AgentInstanceId,
3412}
3413
3414struct NativeInstanceOverlayGuard {
3415    control: Option<NativeLaunchProfileControl>,
3416    instance_id: AgentInstanceId,
3417}
3418
3419struct NativeHarnessMcpOverlayGuard {
3420    control: NativeLaunchProfileControl,
3421    instance_id: AgentInstanceId,
3422}
3423
3424impl NativeHarnessMcpOverlayGuard {
3425    fn retain(self) { std::mem::forget(self); }
3426}
3427
3428impl Drop for NativeHarnessMcpOverlayGuard {
3429    fn drop(&mut self) {
3430        self.control
3431            .clear_native_mcp_server_launch_overlay(self.instance_id);
3432    }
3433}
3434
3435impl NativeInstanceOverlayGuard {
3436    fn retain(mut self) {
3437        self.control = None;
3438    }
3439}
3440
3441impl Drop for NativeInstanceOverlayGuard {
3442    fn drop(&mut self) {
3443        if let Some(control) = self.control.as_ref() {
3444            control.clear_native_instance_launch_overlay(self.instance_id);
3445        }
3446    }
3447}
3448
3449enum PreparedNativeLaunchOverlay {
3450    Environment(NativeLaunchEnvironmentOverlay),
3451    Instance(NativeInstanceLaunchOverlay),
3452}
3453
3454impl NativeEnvironmentSelectionGuard {
3455    fn retain(mut self) {
3456        self.control = None;
3457    }
3458}
3459
3460impl Drop for NativeEnvironmentSelectionGuard {
3461    fn drop(&mut self) {
3462        if let Some(control) = self.control.as_ref() {
3463            control.clear_native_launch_profile_selection(self.instance_id);
3464        }
3465    }
3466}
3467
3468struct SessionMaterializationGuard<'a> {
3469    shared: &'a NodeShared,
3470    id: Option<MaterializationId>,
3471    /// Absolute ProviderHome (`home/`) under the session materialization root.
3472    /// Used for Claude station network `--settings` sidecar writes.
3473    provider_home: Option<PathBuf>,
3474}
3475
3476impl SessionMaterializationGuard<'_> {
3477    fn id(&self) -> Option<&MaterializationId> {
3478        self.id.as_ref()
3479    }
3480
3481    fn provider_home(&self) -> Option<&Path> {
3482        self.provider_home.as_deref()
3483    }
3484
3485    fn retain(mut self) {
3486        self.id = None;
3487    }
3488}
3489
3490impl Drop for SessionMaterializationGuard<'_> {
3491    fn drop(&mut self) {
3492        if let Some(id) = self.id.take() {
3493            let _ = self.shared.cleanup_materialization(&id);
3494        }
3495    }
3496}
3497
3498
3499/// Port: product-side reactions to session lifecycle transitions
3500/// (station lease, harness MCP overlay/registry, environment launch overlays).
3501trait SessionLifecycleHook {
3502    /// A binding was removed (unbind or spawn rollback).
3503    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3504    /// A session reached a terminal control event that revokes its harness MCP door.
3505    fn on_session_ended(&self, address: &SessionAddress);
3506    /// Node shutdown (or drop): revoke every harness MCP reservation.
3507    fn on_shutdown(&self);
3508    /// A harness MCP reservation was aborted before activation.
3509    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3510    /// Release session-owned materialization for a removed binding.
3511    fn cleanup_session_materialization(
3512        &self,
3513        address: &SessionAddress,
3514        binding: &SessionBinding,
3515    ) -> Result<(), NodeFailure>;
3516}
3517
3518impl SessionLifecycleHook for NodeShared {
3519    fn on_session_ended(&self, address: &SessionAddress) {
3520        self.retire_session_grok_leader(address.session.instance_id);
3521        if !self.shutdown.load(Ordering::Acquire) {
3522            if let Some(file) = self.session_resume_journal() {
3523                crate::pty_resume::remove(&file, address.session.instance_id.0);
3524            }
3525        }
3526        if let Some(accounts) = self.m4a_accounts.as_ref() {
3527            let instance = address.session.instance_id;
3528            let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3529            for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3530                accounts.remove_session_file(nick);
3531            }
3532        }
3533        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3534            registry.revoke_session(address);
3535        }
3536        if let Some(control) = self.native_launch_profile_control.as_ref() {
3537            control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3538        }
3539    }
3540
3541    fn on_shutdown(&self) {
3542        let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3543            .into_values()
3544            .collect();
3545        for socket in leaders {
3546            crate::grok_leader::retire(&socket);
3547        }
3548        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3549            let instances = registry.shutdown();
3550            if let Some(control) = self.native_launch_profile_control.as_ref() {
3551                for instance_id in instances {
3552                    control.clear_native_mcp_server_launch_overlay(instance_id);
3553                }
3554            }
3555        }
3556    }
3557
3558    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3559        if let Some(control) = self.native_launch_profile_control.as_ref() {
3560            control.clear_native_mcp_server_launch_overlay(instance_id);
3561        }
3562    }
3563
3564    fn cleanup_session_materialization(
3565        &self,
3566        address: &SessionAddress,
3567        binding: &SessionBinding,
3568    ) -> Result<(), NodeFailure> {
3569        self.cleanup_session_owned_materialization(address, binding)
3570    }
3571
3572    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3573        // Exclusive dig2browser station profile lease is node-local; clear
3574        // on every session unbind / rollback. Never ImportSession/cookies.
3575        #[cfg(feature = "dig2-station-probe")]
3576        {
3577            crate::dig2_station_lease::release_lease_for_holder(
3578                &self.browser_station_leases,
3579                address.session.instance_id,
3580            );
3581        }
3582        if let Some(control) = self.native_launch_profile_control.as_ref() {
3583            control.clear_native_mcp_server_launch_overlay(
3584                address.session.instance_id,
3585            );
3586        }
3587        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3588            registry.revoke_session(address);
3589        }
3590        if removed
3591            .is_some_and(|binding| {
3592                binding.environment_profile.is_some()
3593                    || binding.bundle.is_some()
3594                    || binding.context.is_some()
3595            })
3596        {
3597            if let Some(control) = self.native_launch_profile_control.as_ref() {
3598                control.clear_native_launch_profile_selection(
3599                    address.session.instance_id,
3600                );
3601                control.clear_native_instance_launch_overlay(
3602                    address.session.instance_id,
3603                );
3604            }
3605        }
3606    }
3607}
3608
3609struct SpawnPrepareRequest<'r> {
3610    instance_id: AgentInstanceId,
3611    address: &'r SessionAddress,
3612    provider: &'r AgentId,
3613    mode: SessionMode,
3614    environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3615    bundle: Option<&'r ResolvedBundleReceipt>,
3616    context: Option<&'r ResolvedContextPackReceipt>,
3617    managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3618    harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3619    approval_level: ApprovalLevel,
3620    network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3621    browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3622}
3623
3624/// Guards held between "session identity minted" and "launch committed".
3625/// Field order is the drop order and matches the pre-refactor local order
3626/// (instance overlay, environment selection, materialization, harness, station lease).
3627struct PreparedSpawn<'a> {
3628    instance_overlay: Option<NativeInstanceOverlayGuard>,
3629    environment_selection: Option<NativeEnvironmentSelectionGuard>,
3630    materialization_guard: Option<SessionMaterializationGuard<'a>>,
3631    harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3632    #[cfg(feature = "dig2-station-probe")]
3633    browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3634    materialization_id: Option<MaterializationId>,
3635}
3636
3637impl PreparedSpawn<'_> {
3638    /// Session-owned launch state now belongs to the binding.
3639    fn retain_session_guards(&mut self) {
3640        if let Some(selection) = self.environment_selection.take() {
3641            selection.retain();
3642        }
3643        if let Some(overlay) = self.instance_overlay.take() {
3644            overlay.retain();
3645        }
3646        if let Some(guard) = self.materialization_guard.take() {
3647            guard.retain();
3648        }
3649    }
3650
3651    /// Launch committed: keep harness overlay and station lease for the session lifetime.
3652    fn retain_launch_guards(&mut self) {
3653        if let Some(overlay) = self.harness_mcp_overlay.take() {
3654            overlay.retain();
3655        }
3656        #[cfg(feature = "dig2-station-probe")]
3657        if let Some(guard) = self.browser_station_lease_guard.take() {
3658            guard.retain();
3659        }
3660    }
3661}
3662
3663/// Port: everything product-side that must be prepared before a session is
3664/// bound and started (station lease, harness MCP overlay, materialization,
3665/// environment selection, network overlays, launch overlay).
3666trait SpawnPreparer {
3667    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3668    /// Select the native launch profile for an environment-profile session.
3669    fn select_environment(
3670        &self,
3671        instance_id: AgentInstanceId,
3672        provider: &AgentId,
3673        mode: SessionMode,
3674        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3675    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3676    /// Install a prepared launch overlay for the instance.
3677    fn install_launch_overlay(
3678        &self,
3679        instance_id: AgentInstanceId,
3680        overlay: PreparedNativeLaunchOverlay,
3681    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3682}
3683
3684impl NodeShared {
3685    /// m4a session key of a new runtime: a resume reuses the key (and so the nick and mailbox)
3686    /// of the runtime it replaces; a fresh spawn gets `<incarnation>-<instance>`.
3687    fn m4a_session_key(&self, instance_id: AgentInstanceId) -> String {
3688        let reused = self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
3689        let key = reused.unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0));
3690        self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key.clone());
3691        if let Some(file) = self.session_resume_journal() {
3692            crate::pty_resume::set_session_key(&file, instance_id.0, &key);
3693        }
3694        key
3695    }
3696
3697    fn session_workdir(&self, root: &std::path::Path, instance_id: AgentInstanceId, provider: &AgentId) -> Option<std::path::PathBuf> {
3698        let accounts = self.m4a_accounts.as_ref()?;
3699        let nick = self.m4a_nick_of(instance_id)?;
3700        match accounts.session_workdir(root, provider.as_str(), &nick) {
3701            Ok(dir) => dir,
3702            Err(error) => {
3703                tracing::warn!(%nick, %error, "session folder not prepared; the session runs in the workspace root");
3704                None
3705            }
3706        }
3707    }
3708
3709    fn m4a_nick_of(&self, instance_id: AgentInstanceId) -> Option<String> {
3710        self.m4a_live.lock().unwrap_or_else(|p| p.into_inner()).iter().find(|(_, (i, _))| *i == instance_id).map(|(n, _)| n.clone())
3711    }
3712
3713    /// The managed record of a node session carries its m4a nick as its name (HQ shows it,
3714    /// `/api/spawn` returns it).
3715    fn name_record_after_nick(&self, instance_id: AgentInstanceId, nick: &str) {
3716        let mut records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
3717        for record in records.records.values_mut() {
3718            if record.active_session.as_ref().is_some_and(|a| a.session.instance_id == instance_id) {
3719                record.display_name = nick.to_owned();
3720            }
3721        }
3722    }
3723
3724    /// Gives a node session its own m4a account: identity env, session file, MCP entry and
3725    /// the mailbox listener that wakes it.
3726    fn m4a_session_overlay(
3727        &self,
3728        accounts: &crate::m4a_accounts::M4aSessionAccounts,
3729        m4a_provider: &str,
3730        provider: &AgentId,
3731        instance_id: AgentInstanceId,
3732        environment_overlay: Option<PreparedNativeLaunchOverlay>,
3733        session_key: &str,
3734        mode: SessionMode,
3735    ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3736        let session_key = session_key.to_owned();
3737        if mode == SessionMode::Acp {
3738            return self.m4a_acp_mcp_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key);
3739        }
3740        Ok(                match accounts.provision(m4a_provider, &session_key) {
3741                    Ok(account) => {
3742                        crate::pty_resume::note_account_ref(&session_key, &account.session_id);
3743                        let mut identity = account.env_mutations();
3744                        identity.extend(crate::m4a_accounts::provider_session_env(provider.as_str()));
3745                        let merged = match environment_overlay {
3746                            None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3747                                .map(PreparedNativeLaunchOverlay::Instance),
3748                            Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3749                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3750                            }
3751                            Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3752                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3753                            }
3754                        }
3755                        .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3756                        tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3757                        let mut merged = merged;
3758                        match accounts.write_session_file(&account) {
3759                            Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3760                            Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3761                                Ok(args) if !args.is_empty() => {
3762                                    merged = match merged {
3763                                        PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3764                                            .append_extra_args(args)
3765                                            .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3766                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3767                                        PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3768                                            .into_instance_with_extra_args(args)
3769                                            .map(PreparedNativeLaunchOverlay::Instance)
3770                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3771                                    };
3772                                    tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3773                                }
3774                                Ok(_) => {}
3775                                Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3776                            },
3777                        }
3778                        self.m4a_live
3779                            .lock()
3780                            .unwrap_or_else(|p| p.into_inner())
3781                            .insert(account.nick.clone(), (instance_id, provider.clone()));
3782                        self.name_record_after_nick(instance_id, &account.nick);
3783                        tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a nick bound to session");
3784                        // Letters to this account wake the session: the node listens on its mailbox.
3785                        crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3786                        Some(merged)
3787                    }
3788                    Err(error) => {
3789                        tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3790                        environment_overlay
3791                    }
3792                })
3793    }
3794}
3795
3796impl NodeShared {
3797    /// ACP has no env/argv overlay for this: the session's m4a account rides as an MCP server
3798    /// handed to the agent in session/new, with the account env on that server only.
3799    fn m4a_acp_mcp_overlay(
3800        &self,
3801        accounts: &crate::m4a_accounts::M4aSessionAccounts,
3802        m4a_provider: &str,
3803        provider: &AgentId,
3804        instance_id: AgentInstanceId,
3805        environment_overlay: Option<PreparedNativeLaunchOverlay>,
3806        session_key: &str,
3807    ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3808        let Some(control) = self.native_launch_profile_control.as_ref() else {
3809            tracing::warn!(%provider, "m4a acp account not assigned: no launch profile control");
3810            return Ok(environment_overlay);
3811        };
3812        let account = match accounts.provision(m4a_provider, session_key) {
3813            Ok(a) => a,
3814            Err(error) => {
3815                tracing::warn!(%provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3816                return Ok(environment_overlay);
3817            }
3818        };
3819        let session_file = match accounts.write_session_file(&account) {
3820            Ok(p) => p,
3821            Err(error) => {
3822                tracing::warn!(nick = %account.nick, %error, "m4a session file not written");
3823                return Ok(environment_overlay);
3824            }
3825        };
3826        let env_entries: Vec<(OsString, OsString)> = account
3827            .env_mutations()
3828            .into_iter()
3829            .chain(crate::m4a_accounts::provider_session_env(provider.as_str()))
3830            .filter_map(|m| m.value.map(|v| (m.key, v)))
3831            .collect();
3832        let args: Vec<OsString> = vec![
3833            "--session-file".into(),
3834            session_file.into_os_string(),
3835            "mcp".into(),
3836            "--provider".into(),
3837            provider.as_str().into(),
3838        ];
3839        let overlay = NativeMcpServerLaunchOverlay::new(
3840            provider.clone(),
3841            crate::m4a_accounts::mcp_server_name(provider.as_str()).as_str(),
3842            accounts.bin().as_os_str().to_os_string(),
3843            args,
3844            env_entries,
3845            Vec::new(),
3846        );
3847        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())) {
3848            Ok(_) => tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a mcp server delivered over acp"),
3849            Err(error) => {
3850                tracing::warn!(nick = %account.nick, %error, "m4a acp mcp server not delivered");
3851                return Ok(environment_overlay);
3852            }
3853        }
3854        self.m4a_live
3855            .lock()
3856            .unwrap_or_else(|p| p.into_inner())
3857            .insert(account.nick.clone(), (instance_id, provider.clone()));
3858        self.name_record_after_nick(instance_id, &account.nick);
3859        crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3860        Ok(environment_overlay)
3861    }
3862}
3863
3864impl SpawnPreparer for NodeShared {
3865    fn select_environment(
3866        &self,
3867        instance_id: AgentInstanceId,
3868        provider: &AgentId,
3869        mode: SessionMode,
3870        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3871    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3872        self.select_environment_profile(instance_id, provider, mode, environment_profile)
3873    }
3874
3875    fn install_launch_overlay(
3876        &self,
3877        instance_id: AgentInstanceId,
3878        overlay: PreparedNativeLaunchOverlay,
3879    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3880        self.install_prepared_launch_overlay(instance_id, overlay)
3881    }
3882
3883    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3884        let SpawnPrepareRequest {
3885            instance_id,
3886            address,
3887            provider,
3888            mode,
3889            environment_profile,
3890            bundle,
3891            context,
3892            managed_authority,
3893            harness_mcp,
3894            approval_level,
3895            network_allowlist,
3896            browser_profile_id,
3897        } = request;
3898        // Track A follow-on: exclusive node-local BrowserStationLease after
3899        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
3900        // never ImportSession / cookies on C2. Linux/feature-off: probe
3901        // already refused or stub; this block is cfg'd out when feature off.
3902        #[cfg(feature = "dig2-station-probe")]
3903        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3904            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3905            match acquire_lease(
3906                &self.browser_station_leases,
3907                profile_id,
3908                instance_id,
3909                unix_time_ms(),
3910            ) {
3911                Ok(guard) => Some(guard),
3912                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3913                    return Err(failure(
3914                        NodeFailureCode::BrowserStationProfileBusy,
3915                        "dig2browser station profile is already leased by another session",
3916                    ));
3917                }
3918                Err(BrowserStationLeaseError::CapacityExceeded) => {
3919                    return Err(failure(
3920                        NodeFailureCode::BrowserStationProfileBusy,
3921                        "dig2browser station profile lease capacity exhausted",
3922                    ));
3923                }
3924                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3925                    return Err(failure(
3926                        NodeFailureCode::BrowserStationProfileBusy,
3927                        "session already holds a dig2browser station profile lease",
3928                    ));
3929                }
3930            }
3931        } else {
3932            None
3933        };
3934        #[cfg(not(feature = "dig2-station-probe"))]
3935        let _ = browser_profile_id;
3936        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3937            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
3938            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
3939            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
3940            // both read the identical overlay resolved below, one as child
3941            // environment, the other translated into one stdio MCP server
3942            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
3943            // only, and nothing downstream reads a harness-MCP overlay for
3944            // that transport yet.
3945            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3946            {
3947                return Err(failure(
3948                    NodeFailureCode::BindingMismatch,
3949                    "harness MCP reservation does not match the exact PTY/ACP provider",
3950                ));
3951            }
3952            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3953            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3954                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3955            })?;
3956            // Every name below is the caller's, carried in the reservation's
3957            // launch description: which environment variables receive the
3958            // endpoint, token and helper program, what the MCP server is called,
3959            // which argv it gets and which stale variables are erased.
3960            let launch = prepared.launch();
3961            let mut env_entries = vec![
3962                (
3963                    OsString::from(launch.endpoint_env.as_str()),
3964                    prepared.endpoint().as_os_str().to_os_string(),
3965                ),
3966                (
3967                    OsString::from(launch.token_env.as_str()),
3968                    prepared.token().expose().into(),
3969                ),
3970                (
3971                    OsString::from(launch.program_env.as_str()),
3972                    prepared.helper_program().as_os_str().to_os_string(),
3973                ),
3974            ];
3975            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3976                env_entries.push(trace_entry);
3977            }
3978            if let Some(door) = crate::door::door_listen_addr() {
3979                env_entries.push((
3980                    OsString::from("HATCHERY_NODE_DOOR"),
3981                    OsString::from(door.to_string()),
3982                ));
3983                // Same key shape as HQ browser/draft ids:
3984                // `pty-{incarnation}-{workspace}-{instance}` — never reuse a
3985                // slot across node boots when the instance counter resets.
3986                let session_key = format!(
3987                    "pty-{}-{}-{}",
3988                    self.incarnation_id,
3989                    address.workspace_id.as_str(),
3990                    address.session.instance_id.0
3991                );
3992                env_entries.push((
3993                    OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3994                    OsString::from(session_key),
3995                ));
3996            }
3997            let overlay = NativeMcpServerLaunchOverlay::new(
3998                provider.clone(),
3999                launch.server_name.as_str(),
4000                prepared.helper_program().as_os_str().to_os_string(),
4001                launch.args.iter().map(OsString::from).collect(),
4002                env_entries,
4003                launch.scrub_env.iter().map(OsString::from).collect(),
4004            ).map_err(|_| failure(
4005                NodeFailureCode::HarnessMcpUnavailable,
4006                "harness MCP launch overlay is unavailable",
4007            ))?;
4008            control
4009                .install_native_mcp_server_launch_overlay(instance_id, overlay)
4010                .map_err(|_| failure(
4011                    NodeFailureCode::HarnessMcpUnavailable,
4012                    "harness MCP launch overlay could not be installed",
4013                ))?;
4014            // State-change line: the overlay is installed and will be read
4015            // back by the PTY child's environment or, for ACP,
4016            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
4017            // time -- no secrets here, only which instance/provider/mode
4018            // now carries a harness-MCP door.
4019            tracing::info!(
4020                node_id = %self.node_id,
4021                instance_id = ?instance_id,
4022                provider = %provider,
4023                mode = ?mode,
4024                "harness MCP overlay installed for spawn",
4025            );
4026            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
4027        } else {
4028            None
4029        };
4030        let prepared_materialization = self.prepare_session_materialization(
4031            &address,
4032            &provider,
4033            mode,
4034            environment_profile,
4035            bundle,
4036            context,
4037            managed_authority.cloned(),
4038        )?;
4039        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
4040            Some((overlay, guard)) => (Some(overlay), Some(guard)),
4041            None => (None, None),
4042        };
4043        let materialization_id = materialization_guard
4044            .as_ref()
4045            .and_then(SessionMaterializationGuard::id)
4046            .cloned();
4047        let mut environment_selection = SpawnPreparer::select_environment(self, 
4048            instance_id,
4049            &provider,
4050            mode,
4051            environment_profile,
4052        )?;
4053        // Station network overlays: Codex `-c network_access` and/or Claude
4054        // ProviderHome `--settings` sidecar (permits → allowedDomains).
4055        let provider_home = materialization_guard
4056            .as_ref()
4057            .and_then(SessionMaterializationGuard::provider_home);
4058        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
4059            &provider,
4060            mode,
4061            approval_level,
4062            network_allowlist,
4063            provider_home,
4064        )?;
4065        let mut network_overlay_args = network_overlay_args;
4066        // Auto-permit (default on): pre-trust cwd / onboarding in provider config + auto/yolo flags.
4067        {
4068            let cwd = self.workspace_root(&address.workspace_id).ok().map(PathBuf::from);
4069            let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4070            if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") {
4071                homes.extend(std::env::split_paths(&extra));
4072            }
4073            if let Some(h) = provider_home { homes.push(h.to_path_buf()); }
4074            crate::auto_permit::prepare(provider.as_str(), &homes, cwd.as_deref());
4075            network_overlay_args.extend(crate::auto_permit::launch_args(provider.as_str(), mode));
4076        }
4077        if mode == SessionMode::Acp && crate::auto_permit::acp_auto_approve(provider.as_str()) {
4078            crate::auto_permit::register_acp(instance_id.0, provider.as_str());
4079        }
4080        // Session registry: journal the session so it is resumed after a node restart.
4081        if matches!(mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::enabled() && crate::pty_resume::supported(provider.as_str()) {
4082            if let Some(file) = self.session_resume_journal() {
4083                crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
4084                    instance_id: instance_id.0,
4085                    provider: provider.as_str().to_owned(),
4086                    workspace_id: address.workspace_id.as_str().to_owned(),
4087                    provider_session_id: String::new(),
4088                    session_key: self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned()
4089                        .or_else(|| self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned())
4090                        .unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0)),
4091                    cwd: String::new(),
4092                    acp: mode == SessionMode::Acp,
4093                    options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
4094                    cols: 0,
4095                    rows: 0,
4096                });
4097                tracing::info!(instance_id = instance_id.0, provider = %provider, "session journalled for restart resume");
4098            }
4099        }
4100        if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
4101            network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
4102        }
4103        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
4104            (prepared, args) if args.is_empty() => prepared,
4105            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
4106                overlay.append_extra_args(args).map_err(|_| {
4107                    failure(
4108                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4109                        "network allowlist overlay could not merge with instance launch args",
4110                    )
4111                })?;
4112                Some(PreparedNativeLaunchOverlay::Instance(overlay))
4113            }
4114            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
4115                Some(PreparedNativeLaunchOverlay::Instance(
4116                    overlay
4117                        .into_instance_with_extra_args(args)
4118                        .map_err(|_| {
4119                            failure(
4120                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4121                                "network allowlist overlay could not merge with environment overlay",
4122                            )
4123                        })?,
4124                ))
4125            }
4126            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
4127                NativeInstanceLaunchOverlay::new(
4128                    provider.clone(),
4129                    match mode {
4130                        SessionMode::Pty => TransportKind::Pty,
4131                        SessionMode::Acp => TransportKind::Acp,
4132                        SessionMode::Inline => TransportKind::Pipe,
4133                    },
4134                    Vec::new(),
4135                    args,
4136                )
4137                .map_err(|_| {
4138                    failure(
4139                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4140                        "network allowlist overlay is invalid for this transport",
4141                    )
4142                })?,
4143            )),
4144        };
4145        // Per-session m4a account: own nick + capability for this PTY child only, on its own
4146        // overlay or appended to the overlay the spawn already carries.
4147        #[cfg(unix)]
4148        // `HATCHERY_M4A_SIGNER_SHELL=1` also gives operator shells an identity (live canaries).
4149        let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
4150            (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
4151        });
4152        #[cfg(unix)]
4153        // A provider session with its own m4a account (MCP server + cap) takes that path; the
4154        // signer handle is for sessions without one (operator shells).
4155        let signer_provider = signer_provider.filter(|p| *p == "shell" || self.m4a_accounts.is_none());
4156        let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
4157            (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
4158                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
4159                let uid = unsafe { libc_geteuid() };
4160                let handle = proxy.reserve(&session_key, m4a_provider, uid);
4161                let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
4162                tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
4163                Some(vec![
4164                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
4165                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
4166                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
4167                ])
4168            }
4169            _ => None,
4170        };
4171        #[cfg(not(unix))]
4172        let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
4173        let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
4174            (SessionMode::Pty, _, _, Some(identity)) => {
4175                let merged = match environment_overlay {
4176                    None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
4177                    Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
4178                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
4179                    }
4180                    Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
4181                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
4182                    }
4183                }
4184                .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
4185                Some(merged)
4186            }
4187            (SessionMode::Pty | SessionMode::Acp, Some(accounts), Some(m4a_provider), None) => {
4188                let session_key = self.m4a_session_key(instance_id);
4189                self.m4a_session_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key, mode)?
4190            }
4191            (SessionMode::Pty, Some(_), None, _) => {
4192                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4193                environment_overlay
4194            }
4195            _ => environment_overlay,
4196        };
4197        let mut instance_overlay = environment_overlay
4198            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4199            .transpose()?
4200            .flatten();
4201        Ok(PreparedSpawn {
4202            instance_overlay,
4203            environment_selection,
4204            materialization_guard,
4205            harness_mcp_overlay,
4206            #[cfg(feature = "dig2-station-probe")]
4207            browser_station_lease_guard,
4208            materialization_id,
4209        })
4210    }
4211}
4212
4213struct SessionRecords {
4214    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4215}
4216
4217struct SpawnIdempotencyCache {
4218    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4219}
4220
4221struct SpawnIdempotencyEntry {
4222    value: SpawnIdempotencyValue,
4223    expires_at: Instant,
4224}
4225
4226enum SpawnIdempotencyValue {
4227    Standard {
4228        spec: SpawnSpec,
4229        result: Result<ResolvedSpawnReceipt, NodeFailure>,
4230    },
4231    Managed {
4232        request: ManagedWorktreeSpawnRequest,
4233        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4234    },
4235    HarnessMcp,
4236}
4237
4238enum DurableManagedSpawnReplayDecision {
4239    Reserved,
4240    PendingLinked,
4241    Committed(ManagedWorktreeSpawnReceipt),
4242}
4243
4244impl SpawnIdempotencyValue {
4245    fn references_context(&self, context_id: &SpawnContextId) -> bool {
4246        match self {
4247            Self::Standard { result, .. } => result
4248                .as_ref()
4249                .ok()
4250                .and_then(|receipt| receipt.context.as_ref()),
4251            Self::Managed { result, .. } => result
4252                .as_ref()
4253                .ok()
4254                .and_then(|receipt| receipt.spawn.context.as_ref()),
4255            Self::HarnessMcp => None,
4256        }
4257        .is_some_and(|receipt| &receipt.id == context_id)
4258    }
4259}
4260
4261#[derive(Clone, Copy)]
4262struct ControllerLease {
4263    connection_id: u64,
4264    expires_at: Instant,
4265}
4266
4267struct NodeEventHistory {
4268    last_sequence: u64,
4269    replay_floor_sequence: u64,
4270    events: VecDeque<NodeEventEnvelope>,
4271    record_providers: BTreeMap<SessionRecordId, AgentId>,
4272    removed_record_providers: BTreeMap<u64, AgentId>,
4273}
4274
4275impl NodeEventHistory {
4276    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4277        Self {
4278            last_sequence: 0,
4279            replay_floor_sequence: 1,
4280            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4281            record_providers,
4282            removed_record_providers: BTreeMap::new(),
4283        }
4284    }
4285}
4286
4287impl NodeShared {
4288    /// A node-spawned grok PTY session gets its own leader socket, so its agent backend runs
4289    /// as a descendant of this session's process (with the session's own environment) and
4290    /// never attaches to an operator's shared leader. Same grok home, same login.
4291    fn session_resume_journal(&self) -> Option<PathBuf> {
4292        self.state_path.as_ref().and_then(|p| p.parent()).map(crate::pty_resume::path)
4293    }
4294
4295    /// Node start: resume every journalled session through its provider's resume path,
4296    /// under its old instance number, so HQ shows the same chat.
4297    async fn restore_journalled_sessions(&self) {
4298        let Some(file) = self.session_resume_journal() else { return };
4299        if !crate::pty_resume::enabled() { return; }
4300        for e in crate::pty_resume::load(&file) {
4301            crate::pty_resume::remove(&file, e.instance_id);
4302            // Same m4a session key as before the restart: the session keeps its account and nick.
4303            if !e.session_key.is_empty() {
4304                self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(AgentInstanceId(e.instance_id), e.session_key.clone());
4305            }
4306            let (Ok(ws), Ok(provider)) = (WorkspaceId::new(e.workspace_id.clone()), AgentId::new(e.provider.clone())) else { continue };
4307            if e.acp {
4308                let record_id = {
4309                    let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4310                    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())
4311                };
4312                let cur = self.next_instance_id.load(Ordering::Acquire);
4313                if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4314                let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4315                let resumed = match record_id {
4316                    Some(rid) => SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&rid, size, None)).await.map(|(_, a)| a),
4317                    None => Err(failure(NodeFailureCode::SessionRecordNotResumable, "no record")),
4318                };
4319                // An agent without ACP session/load (claude) cannot resume: same number, fresh chat.
4320                let r = match resumed {
4321                    Ok(a) => Ok(a),
4322                    Err(err) => {
4323                        tracing::info!(instance_id = e.instance_id, message = %err.message, "acp resume unavailable; starting fresh under the same number");
4324                        self.next_instance_id.store(e.instance_id.max(self.next_instance_id.load(Ordering::Acquire).min(e.instance_id)), Ordering::Release);
4325                        SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Acp, size, None)).await
4326                    }
4327                };
4328                match r {
4329                    Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled acp session restored"),
4330                    Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled acp session restore failed"),
4331                }
4332                continue;
4333            }
4334            if e.provider_session_id.is_empty() || !provider_session_exists(&e.provider, &e.provider_session_id) {
4335                // Nothing said yet in that chat: start it fresh under the same number.
4336                let cur = self.next_instance_id.load(Ordering::Acquire);
4337                if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4338                let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4339                match SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Pty, size, None)).await {
4340                    Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session had no transcript; started fresh"),
4341                    Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session restart failed"),
4342                }
4343                continue;
4344            }
4345            let folder = std::path::Path::new(&e.cwd);
4346            let in_session_dir = folder.parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".hatchery-sessions");
4347            let name = match (in_session_dir, folder.file_name()) {
4348                (true, Some(n)) => n.to_string_lossy().into_owned(),
4349                _ => format!("{} #{}", e.provider, e.instance_id),
4350            };
4351            let identity = ProviderSessionIdentity { key: gate4agent_types::ProviderSessionKey::SessionId, id: e.provider_session_id.clone(), transcript_path: None };
4352            let record = match self.index_provider_session(ws, provider, identity, name) {
4353                Ok(r) => r,
4354                Err(err) => { tracing::warn!(instance_id = e.instance_id, message = %err.message, "journalled session not indexed"); continue; }
4355            };
4356            let cur = self.next_instance_id.load(Ordering::Acquire);
4357            if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4358            let size = gate4agent_types::TerminalSize { rows: if e.rows > 0 { e.rows } else { 40 }, columns: if e.cols > 0 { e.cols } else { 120 } };
4359            let r = SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&record.record_id, size, None)).await;
4360            match r {
4361                Ok((_, addr)) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session resumed"),
4362                Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session resume failed"),
4363            }
4364        }
4365    }
4366
4367    fn refresh_session_journal(&self) {
4368        let Some(file) = self.session_resume_journal() else { return };
4369        let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4370        if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
4371        crate::pty_resume::refresh(&file, &homes);
4372        let ids: Vec<(u64, String)> = {
4373            let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4374            records.records.values().filter_map(|r| Some((r.active_session.as_ref()?.session.instance_id.0, r.provider_session.as_ref()?.id.clone()))).collect()
4375        };
4376        for (inst, sid) in ids {
4377            crate::pty_resume::set_session_id(&file, inst, &sid);
4378        }
4379    }
4380
4381    fn session_grok_leader_socket(
4382        &self,
4383        provider: &AgentId,
4384        mode: SessionMode,
4385        instance_id: AgentInstanceId,
4386    ) -> Option<std::path::PathBuf> {
4387        if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4388            return None;
4389        }
4390        let root = self
4391            .m4a_accounts
4392            .as_ref()
4393            .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4394            .unwrap_or_else(std::env::temp_dir);
4395        let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4396        if let Some(previous) = self
4397            .grok_leaders
4398            .lock()
4399            .unwrap_or_else(|p| p.into_inner())
4400            .insert(instance_id, socket.clone())
4401        {
4402            crate::grok_leader::retire(&previous);
4403        }
4404        Some(socket)
4405    }
4406
4407    /// Stops this session's own grok leader, if it had one. Never touches any other leader.
4408    fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4409        let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4410        if let Some(socket) = socket {
4411            crate::grok_leader::retire(&socket);
4412        }
4413    }
4414
4415    fn new_with_incarnation(
4416        handle: Gate4AgentHandle,
4417        access_token: String,
4418        node_id: NodeId,
4419        incarnation_id: NodeIncarnationId,
4420        workspaces: Vec<WorkspaceConfig>,
4421        enabled_providers: Vec<AgentId>,
4422        provider_runtime_statuses: ProviderRuntimeStatuses,
4423        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4424        provider_contracts: Vec<ProviderContractSupport>,
4425        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4426        spawn_profiles: SpawnProfileRegistry,
4427        native_launch_profile_control: Option<NativeLaunchProfileControl>,
4428        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4429        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4430        state_path: Option<PathBuf>,
4431        records: Vec<ManagedSessionRecord>,
4432        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4433        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4434        materializations: Vec<MaterializationOwnershipRecord>,
4435        persistence_warning: Option<String>,
4436        input_settle_timeout_ms: u64,
4437    ) -> Self {
4438        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4439        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4440        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4441        let (harness_mcp_proxy_event_tx, _) =
4442            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4443        let record_providers = records
4444            .iter()
4445            .map(|record| (record.record_id.clone(), record.provider.clone()))
4446            .collect();
4447        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4448            .iter()
4449            .map(|workspace| {
4450                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4451            })
4452            .collect();
4453        for lease in &managed_worktrees {
4454            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4455        }
4456        let managed_worktree_profiles = workspaces.iter()
4457            .map(|workspace| (
4458                workspace.workspace_id.clone(),
4459                workspace.managed_worktree_profiles.clone(),
4460            ))
4461            .collect();
4462        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4463            managed_worktrees,
4464            managed_worktree_tombstones,
4465        ).expect("durable managed worktree registry was validated while loading");
4466        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4467        managed_worktrees.reattach_record_holders(
4468            &managed_worktree_record_holders(&records),
4469            unix_time_ms(),
4470        );
4471        Self {
4472            handle,
4473            access_token,
4474            node_id,
4475            incarnation_id,
4476            started_at_unix_ms: std::time::SystemTime::now()
4477                .duration_since(std::time::UNIX_EPOCH)
4478                .unwrap_or_default()
4479                .as_millis()
4480                .min(u64::MAX as u128) as u64,
4481            workspaces: RwLock::new(
4482                workspaces
4483                    .into_iter()
4484                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4485                    .collect(),
4486            ),
4487            worktree_service_modes,
4488            managed_worktree_profiles,
4489            managed_worktrees: Mutex::new(managed_worktrees),
4490            enabled_providers,
4491            provider_runtime_statuses,
4492            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4493            provider_runtime_monitor,
4494            provider_contracts,
4495            provider_adapter_contracts,
4496            spawn_profiles,
4497            environment_profiles: RwLock::new(BTreeMap::new()),
4498            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4499            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4500            #[cfg(feature = "dig2-station-probe")]
4501            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4502            bundle_catalog: RwLock::new(BundleCatalog::default()),
4503            delivery_store: Mutex::new(None),
4504            context_pack_store: Mutex::new(None),
4505            context_catalog: RwLock::new(ContextPackCatalog::default()),
4506            native_launch_profile_control,
4507            harness_mcp_registry: None,
4508            native_session_catalog,
4509            session_environment_materializer,
4510            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4511            m4a_live: Mutex::new(BTreeMap::new()),
4512            m4a_keys: Mutex::new(BTreeMap::new()),
4513            m4a_reuse_keys: Mutex::new(BTreeMap::new()),
4514            grok_leaders: Mutex::new(BTreeMap::new()),
4515            #[cfg(unix)]
4516            signer_proxy: std::sync::OnceLock::new(),
4517            materializations: Mutex::new(
4518                materializations
4519                    .into_iter()
4520                    .map(|record| (record.id().clone(), record))
4521                    .collect(),
4522            ),
4523            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4524                entries: BTreeMap::new(),
4525            }),
4526            managed_spawn_replays: Mutex::new(BTreeMap::new()),
4527            controller: Mutex::new(None),
4528            provisional_spawns: Mutex::new(BTreeMap::new()),
4529            pending_acp_options: Mutex::new(BTreeMap::new()),
4530            history: Mutex::new(NodeEventHistory::new(record_providers)),
4531            event_tx,
4532            terminal_event_tx,
4533            agent_stream_event_tx,
4534            harness_mcp_proxy_event_tx,
4535            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4536            pty_input_wake: tokio::sync::Notify::new(),
4537            pty_input_marks: Mutex::new(BTreeMap::new()),
4538            next_connection_id: AtomicU64::new(1),
4539            next_instance_id: AtomicU64::new(1),
4540            next_command_id: AtomicU64::new(1),
4541            shutdown: AtomicBool::new(false),
4542            shutdown_notify: Notify::new(),
4543            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4544            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4545            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4546            mutation_gate: AsyncMutex::new(()),
4547            session_bindings: Mutex::new(BTreeMap::new()),
4548            session_records: Mutex::new(SessionRecords {
4549                records: records
4550                    .into_iter()
4551                    .map(|record| (record.record_id.clone(), record))
4552                    .collect(),
4553            }),
4554            state_path,
4555            persistence_error: RwLock::new(
4556                persistence_warning
4557                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
4558            ),
4559            state_transaction: Mutex::new(()),
4560            input_settle_timeout_ms,
4561            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4562            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4563            shell_efficiency_profile: OnceLock::new(),
4564            native_session_gauge: AtomicUsize::new(0),
4565            connection_loop_iterations: AtomicU64::new(0),
4566            connection_events_sent: AtomicU64::new(0),
4567            rejected_commands_total: AtomicU64::new(0),
4568            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4569            drive_loop_iterations_total: AtomicU64::new(0),
4570            drive_loop_iterations_idle: AtomicU64::new(0),
4571            session_record_retention: SessionRecordRetentionConfig::default(),
4572            retired_records_total: AtomicU64::new(0),
4573            #[cfg(feature = "fixture")]
4574            fixture_semantic_hook_policy: false,
4575            #[cfg(feature = "fixture")]
4576            fixture_spawn_managed_worktree_v2_failure_probe:
4577                SpawnManagedWorktreeV2FailureProbe::default(),
4578        }
4579    }
4580
4581    #[cfg(test)]
4582    fn new(
4583        handle: Gate4AgentHandle,
4584        access_token: String,
4585        node_id: NodeId,
4586        workspaces: Vec<WorkspaceConfig>,
4587        enabled_providers: Vec<AgentId>,
4588    ) -> Self {
4589        Self::new_with_incarnation(
4590            handle,
4591            access_token,
4592            node_id,
4593            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4594            workspaces,
4595            enabled_providers,
4596            ProviderRuntimeStatuses::default(),
4597            None,
4598            Vec::new(),
4599            Vec::new(),
4600            SpawnProfileRegistry::default(),
4601            None,
4602            None,
4603            None,
4604            None,
4605            Vec::new(),
4606            Vec::new(),
4607            Vec::new(),
4608            Vec::new(),
4609            None,
4610            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4611        )
4612    }
4613
4614    fn persistence_error(&self) -> Option<String> {
4615        self.persistence_error
4616            .read()
4617            .unwrap_or_else(|poisoned| poisoned.into_inner())
4618            .clone()
4619    }
4620
4621    async fn admit_provider_runtime(
4622        &self,
4623        provider: &AgentId,
4624        requirement: ProviderRuntimeRequirement,
4625    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4626        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4627            let pipe_transport = monitor.supports_pipe_transport(provider);
4628            let acp_transport = monitor.supports_acp_transport(provider);
4629            let provider = provider.clone();
4630            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4631            let (status, admission) = timeout(
4632                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4633                refresh,
4634            )
4635            .await
4636            .map_err(|_| failure(
4637                NodeFailureCode::BackendBusy,
4638                "provider runtime probe exceeded its bounded deadline",
4639            ))?
4640            .map_err(|_| failure(
4641                NodeFailureCode::BackendOperationFailed,
4642                "provider runtime probe task failed",
4643            ))?;
4644            if let Some(status) = status {
4645                self
4646                    .provider_runtime_status_updates
4647                    .write()
4648                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4649                    .insert(status.provider().clone(), status);
4650            }
4651            admission.and_then(|policy| {
4652                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4653            })
4654        } else {
4655            // No live monitor means no catalog to consult -- there is
4656            // nothing here to confirm this provider declares a Pipe or ACP
4657            // transport, so Inline/Acp are rejected the same conservative
4658            // way an undeclared provider always was, rather than admitted
4659            // on trust.
4660            crate::provider_runtime::admit_status(
4661                &self.provider_runtime_statuses,
4662                provider,
4663                false,
4664                false,
4665                requirement,
4666            )
4667        };
4668        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4669        admission.map_err(|error| match error {
4670            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4671                NodeFailureCode::BackendOperationFailed,
4672                "provider launcher is unavailable",
4673            ),
4674            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4675                NodeFailureCode::UnsupportedCapability,
4676                "provider semantic capability is not verified",
4677            ),
4678            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4679                NodeFailureCode::BackendBusy,
4680                "provider runtime probe is already in progress",
4681            ),
4682        })
4683    }
4684
4685    /// Whether the catalog declares a Pipe transport for `provider` --
4686    /// the fact `require_policy` needs to admit or reject
4687    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4688    /// catalog to consult, so this stays conservative and rejects, matching
4689    /// `admit_provider_runtime`'s own no-monitor branch.
4690    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4691        match self.provider_runtime_monitor.as_deref() {
4692            Some(monitor) => monitor.supports_pipe_transport(provider),
4693            None => false,
4694        }
4695    }
4696
4697    /// Whether the catalog declares an ACP transport for `provider` -- the
4698    /// fact `require_policy` needs to admit or reject
4699    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4700    /// `provider_supports_pipe_transport` exactly, including the
4701    /// conservative no-monitor default.
4702    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4703        match self.provider_runtime_monitor.as_deref() {
4704            Some(monitor) => monitor.supports_acp_transport(provider),
4705            None => false,
4706        }
4707    }
4708
4709    /// Grants hook ingestion to a provider whose catalog entry declares a
4710    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4711    ///
4712    /// `admit_status`'s `RawPassthrough` arm hands back
4713    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4714    /// correct for everything it was written to answer, and wrong for hooks
4715    /// specifically. A hook event is not inferred from terminal behaviour,
4716    /// so it has nothing to do with whether a launcher probe verified this
4717    /// CLI version's PTY contract; the authority is the catalog declaring
4718    /// the adapter plus the ingress authenticating the route. Without this
4719    /// the node installs a provider's hooks, the CLI faithfully calls them,
4720    /// and every event is refused -- which is exactly the state grok was in.
4721    ///
4722    /// Applied to both admission paths so the monitor's own derivation and
4723    /// this coarse fallback cannot disagree about the same provider.
4724    fn admit_declared_hook_semantics(
4725        &self,
4726        provider: &AgentId,
4727        policy: ProviderRuntimePolicy,
4728    ) -> ProviderRuntimePolicy {
4729        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4730            return policy;
4731        }
4732        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4733            contract.provider == *provider && contract.family == AdapterFamily::Hook
4734        });
4735        if !declares_hook {
4736            return policy;
4737        }
4738        ProviderRuntimePolicy::new(
4739            policy.raw_pty_lifecycle,
4740            policy.semantic_readiness,
4741            policy.structured_prompt,
4742            policy.provider_session_identity,
4743            policy.semantic_resume,
4744            true,
4745        )
4746        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4747    }
4748
4749    /// Station network allowlist → provider launch overlay argv.
4750    ///
4751    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4752    ///   `provider_native` under Moderate (PTY or ACP).
4753    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4754    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4755    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4756    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4757    ///   no overlay.
4758    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4759    ///   empty overlay when no `provider_native`; invented native keys already
4760    ///   refuse at load / resolve.
4761    ///
4762    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4763    fn resolve_network_allowlist_overlay_args(
4764        &self,
4765        provider: &AgentId,
4766        mode: SessionMode,
4767        approval_level: ApprovalLevel,
4768        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4769        provider_home: Option<&Path>,
4770    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4771        let Some(allowlist_id) = network_allowlist else {
4772            return Ok(Vec::new());
4773        };
4774        let catalog = self
4775            .network_allowlist_catalog
4776            .read()
4777            .unwrap_or_else(|poisoned| poisoned.into_inner());
4778        let Some(entry) = catalog.get(allowlist_id) else {
4779            return Err(failure(
4780                NodeFailureCode::UnknownNetworkAllowlist,
4781                "spawn network allowlist is unavailable on this node",
4782            ));
4783        };
4784
4785        // Claude: settings-shaped overlay from permits (not provider_native).
4786        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4787            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4788                |message| {
4789                    failure(
4790                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4791                        &message,
4792                    )
4793                },
4794            )?;
4795            if !matches!(mode, SessionMode::Pty) {
4796                return Err(failure(
4797                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4798                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4799                ));
4800            }
4801            let Some(home) = provider_home else {
4802                return Err(failure(
4803                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4804                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4805                ));
4806            };
4807            let domains =
4808                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4809            if domains.is_empty() {
4810                return Err(failure(
4811                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4812                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4813                ));
4814            }
4815            let settings_path = home.join(
4816                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4817            );
4818            let json =
4819                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4820            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4821                |_| {
4822                    failure(
4823                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4824                        "Claude station network settings sidecar could not be written under ProviderHome",
4825                    )
4826                },
4827            )?;
4828            let overlay =
4829                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4830            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4831        }
4832
4833        let Some(native) = entry.provider_native.as_ref() else {
4834            return Ok(Vec::new());
4835        };
4836        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4837            provider.as_str(),
4838            approval_level,
4839            native,
4840        )
4841        .map_err(|message| {
4842            failure(
4843                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4844                &message,
4845            )
4846        })?;
4847        if overlay.is_empty() {
4848            return Ok(Vec::new());
4849        }
4850        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4851            return Err(failure(
4852                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4853                "Codex network_access -c overlay requires PTY or ACP transport",
4854            ));
4855        }
4856        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4857    }
4858
4859    /// Backward-compatible alias used by Codex-focused unit tests.
4860    fn resolve_codex_network_access_overlay_args(
4861        &self,
4862        provider: &AgentId,
4863        mode: SessionMode,
4864        approval_level: ApprovalLevel,
4865        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4866    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4867        self.resolve_network_allowlist_overlay_args(
4868            provider,
4869            mode,
4870            approval_level,
4871            network_allowlist,
4872            None,
4873        )
4874    }
4875
4876    fn resolve_environment_profile(
4877        &self,
4878        resolved: &ResolvedSpawnSpec,
4879    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4880        // Station network allowlist catalog enforce (plan
4881        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4882        // catalog — unknown ids refuse rather than silent ambient. Runs even
4883        // when no environment_profile_id is present so a bare allowlist still
4884        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4885        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4886            let catalog = self
4887                .network_allowlist_catalog
4888                .read()
4889                .unwrap_or_else(|poisoned| poisoned.into_inner());
4890            let Some(entry) = catalog.get(allowlist_id) else {
4891                return Err(failure(
4892                    NodeFailureCode::UnknownNetworkAllowlist,
4893                    "spawn network allowlist is unavailable on this node",
4894                ));
4895            };
4896            // Track B: refuse provider-native mappings this provider cannot
4897            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4898            if let Some(native) = entry.provider_native.as_ref() {
4899                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4900                    resolved.provider.as_str(),
4901                    resolved.approval_level,
4902                    native,
4903                ) {
4904                    return Err(failure(
4905                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4906                        &message,
4907                    ));
4908                }
4909            }
4910        }
4911        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4912        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4913        // cheap named-pipe path reachability must succeed (Windows
4914        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4915        // NodeFailureCode. Runs even without environment_profile_id (same
4916        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4917        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4918        // acquired later in spawn_session_with_deadline (after instance id).
4919        #[cfg(feature = "dig2-station-probe")]
4920        if resolved.browser_profile_id.is_some() {
4921            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4922            match probe_station_reachable() {
4923                Ok(()) => {}
4924                Err(StationProbeError::UnsupportedPlatform)
4925                | Err(StationProbeError::InvalidPipeSuffix) => {
4926                    return Err(failure(
4927                        NodeFailureCode::BrowserStationProbeUnavailable,
4928                        "dig2browser station probe unavailable on this platform",
4929                    ));
4930                }
4931                Err(StationProbeError::Unreachable) => {
4932                    return Err(failure(
4933                        NodeFailureCode::BrowserStationUnreachable,
4934                        "dig2browser station named-pipe is unreachable",
4935                    ));
4936                }
4937            }
4938        }
4939        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4940            return Ok(None);
4941        };
4942        let profiles = self
4943            .environment_profiles
4944            .read()
4945            .unwrap_or_else(|poisoned| poisoned.into_inner());
4946        let binding = profiles.get(profile_id).ok_or_else(|| {
4947            failure(
4948                NodeFailureCode::UnknownEnvironmentProfile,
4949                "spawn environment profile is unavailable on this node",
4950            )
4951        })?;
4952        if binding.provider != resolved.provider
4953            || binding.native_profile_id(resolved.mode).is_none()
4954        {
4955            return Err(failure(
4956                NodeFailureCode::EnvironmentProfileBindingMismatch,
4957                "spawn environment profile does not match the provider and mode",
4958            ));
4959        }
4960        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4961        // after catalog + optional dig2 probe above. Empty/whitespace ids
4962        // already refuse at Spawn*Id construction.
4963        Ok(Some(ResolvedEnvironmentProfileReceipt {
4964            profile_id: binding.id.clone(),
4965            profile_revision: binding.revision.clone(),
4966            network_allowlist: resolved.network_allowlist.clone(),
4967            browser_profile_id: resolved.browser_profile_id.clone(),
4968        }))
4969    }
4970
4971    fn resolve_bundle(
4972        &self,
4973        resolved: &ResolvedSpawnSpec,
4974        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4975    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4976        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4977            return Ok(None);
4978        };
4979        let catalog = self.bundle_catalog
4980            .read()
4981            .unwrap_or_else(|poisoned| poisoned.into_inner());
4982        let bundle = catalog.get(bundle_id).ok_or_else(|| {
4983                failure(
4984                    NodeFailureCode::UnknownBundle,
4985                    "spawn bundle is unavailable on this node",
4986                )
4987            })?;
4988        self.bundle_layout(
4989            &resolved.provider,
4990            resolved.mode,
4991            environment_profile,
4992            bundle,
4993        )?;
4994        Ok(Some(bundle.receipt()))
4995    }
4996
4997    fn begin_delivery_stage(
4998        &self,
4999        manifest: DeliveryBundleManifestV2,
5000    ) -> Result<NodeResponse, NodeFailure> {
5001        let mut store = self
5002            .delivery_store
5003            .lock()
5004            .unwrap_or_else(|poisoned| poisoned.into_inner());
5005        let store = store.as_mut().ok_or_else(|| {
5006            failure(
5007                NodeFailureCode::DeliveryStageStorageFailed,
5008                "node delivery store is unavailable",
5009            )
5010        })?;
5011        let begun = store.begin(manifest).map_err(delivery_failure)?;
5012        Ok(NodeResponse::DeliveryStageBegun {
5013            stage_id: begun.stage_id,
5014            manifest_digest: begun.manifest_digest,
5015            missing_blobs: begun.missing_blobs,
5016        })
5017    }
5018
5019    fn put_delivery_blob_chunk(
5020        &self,
5021        stage_id: DeliveryStageId,
5022        blob_digest: DeliveryBlobDigestV1,
5023        offset: u64,
5024        chunk_hex: DeliveryBlobChunkHexV1,
5025    ) -> Result<NodeResponse, NodeFailure> {
5026        let mut store = self
5027            .delivery_store
5028            .lock()
5029            .unwrap_or_else(|poisoned| poisoned.into_inner());
5030        let store = store.as_mut().ok_or_else(|| {
5031            failure(
5032                NodeFailureCode::DeliveryStageStorageFailed,
5033                "node delivery store is unavailable",
5034            )
5035        })?;
5036        let accepted = store
5037            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
5038            .map_err(delivery_failure)?;
5039        Ok(NodeResponse::DeliveryBlobChunkAccepted {
5040            stage_id: accepted.stage_id,
5041            blob_digest: accepted.blob_digest,
5042            next_offset: accepted.next_offset,
5043        })
5044    }
5045
5046    fn commit_delivery_stage(
5047        &self,
5048        stage_id: DeliveryStageId,
5049    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
5050        let mut store = self
5051            .delivery_store
5052            .lock()
5053            .unwrap_or_else(|poisoned| poisoned.into_inner());
5054        let store = store.as_mut().ok_or_else(|| {
5055            failure(
5056                NodeFailureCode::DeliveryStageStorageFailed,
5057                "node delivery store is unavailable",
5058            )
5059        })?;
5060        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
5061        let mut catalog = self
5062            .bundle_catalog
5063            .write()
5064            .unwrap_or_else(|poisoned| poisoned.into_inner());
5065        let mut next_catalog = catalog.clone();
5066        next_catalog
5067            .insert_idempotent(prepared.bundle().clone())
5068            .map_err(|_| {
5069                failure(
5070                    NodeFailureCode::DeliveryStageConflict,
5071                    "delivery bundle identity conflicts with installed content",
5072                )
5073            })?;
5074        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
5075        *catalog = next_catalog;
5076        Ok(receipt)
5077    }
5078
5079    fn abort_delivery_stage(
5080        &self,
5081        stage_id: &DeliveryStageId,
5082    ) -> Result<(), NodeFailure> {
5083        let mut store = self
5084            .delivery_store
5085            .lock()
5086            .unwrap_or_else(|poisoned| poisoned.into_inner());
5087        let store = store.as_mut().ok_or_else(|| {
5088            failure(
5089                NodeFailureCode::DeliveryStageStorageFailed,
5090                "node delivery store is unavailable",
5091            )
5092        })?;
5093        store.abort(stage_id).map_err(delivery_failure)
5094    }
5095
5096    fn resolve_context(
5097        &self,
5098        resolved: &ResolvedSpawnSpec,
5099    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
5100        let Some(context_id) = resolved.context_id.as_ref() else {
5101            return Ok(None);
5102        };
5103        self.context_catalog
5104            .read()
5105            .unwrap_or_else(|poisoned| poisoned.into_inner())
5106            .get(context_id)
5107            .map(|context| context.receipt().clone())
5108            .ok_or_else(|| {
5109                failure(
5110                    NodeFailureCode::UnknownContextPack,
5111                    "spawn context pack is unavailable on this node",
5112                )
5113            })
5114            .map(Some)
5115    }
5116
5117    fn bundle_layout(
5118        &self,
5119        provider: &AgentId,
5120        mode: SessionMode,
5121        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5122        bundle: &NodeBundle,
5123    ) -> Result<BundleProviderLayout, NodeFailure> {
5124        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
5125            failure(
5126                NodeFailureCode::BundleBindingMismatch,
5127                "spawn bundle does not match the provider and mode",
5128            )
5129        })?;
5130        if layout != BundleProviderLayout::Codex {
5131            return Ok(layout);
5132        }
5133        let profile = environment_profile.and_then(|receipt| {
5134            let profiles = self
5135                .environment_profiles
5136                .read()
5137                .unwrap_or_else(|poisoned| poisoned.into_inner());
5138            let binding = profiles.get(&receipt.profile_id)?;
5139            if binding.revision != receipt.profile_revision
5140                || &binding.provider != provider
5141                || binding.native_profile_id(mode).is_none()
5142            {
5143                return None;
5144            }
5145            self.environment_materialization_profiles
5146                .read()
5147                .unwrap_or_else(|poisoned| poisoned.into_inner())
5148                .get(&receipt.profile_id)
5149                .cloned()
5150        });
5151        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
5152            return Err(failure(
5153                NodeFailureCode::BundleBindingMismatch,
5154                "Codex bundle requires an exact isolated CODEX_HOME profile",
5155            ));
5156        }
5157        Ok(layout)
5158    }
5159
5160    fn select_environment_profile(
5161        &self,
5162        instance_id: AgentInstanceId,
5163        provider: &AgentId,
5164        mode: SessionMode,
5165        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5166    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
5167        let Some(environment_profile) = environment_profile else {
5168            return Ok(None);
5169        };
5170        let native_profile_id = {
5171            let profiles = self
5172                .environment_profiles
5173                .read()
5174                .unwrap_or_else(|poisoned| poisoned.into_inner());
5175            let binding = profiles
5176                .get(&environment_profile.profile_id)
5177                .ok_or_else(|| {
5178                    failure(
5179                        NodeFailureCode::UnknownEnvironmentProfile,
5180                        "session environment profile is unavailable on this node",
5181                    )
5182                })?;
5183            if &binding.revision != &environment_profile.profile_revision
5184                || &binding.provider != provider
5185            {
5186                return Err(failure(
5187                    NodeFailureCode::EnvironmentProfileBindingMismatch,
5188                    "session environment profile revision or provider changed",
5189                ));
5190            }
5191            binding.native_profile_id(mode).cloned().ok_or_else(|| {
5192                failure(
5193                    NodeFailureCode::EnvironmentProfileBindingMismatch,
5194                    "session environment profile does not support this mode",
5195                )
5196            })?
5197        };
5198        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5199            failure(
5200                NodeFailureCode::BackendOperationFailed,
5201                "native environment profile controller is unavailable",
5202            )
5203        })?;
5204        control
5205            .select_native_launch_profile(instance_id, native_profile_id)
5206            .map_err(|_| {
5207                failure(
5208                    NodeFailureCode::BackendOperationFailed,
5209                    "native environment profile selection failed",
5210                )
5211            })?;
5212        Ok(Some(NativeEnvironmentSelectionGuard {
5213            control: Some(control),
5214            instance_id,
5215        }))
5216    }
5217
5218    fn install_prepared_launch_overlay(
5219        &self,
5220        instance_id: AgentInstanceId,
5221        overlay: PreparedNativeLaunchOverlay,
5222    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
5223        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5224            failure(
5225                NodeFailureCode::BackendOperationFailed,
5226                "native launch profile controller is unavailable",
5227            )
5228        })?;
5229        match overlay {
5230            PreparedNativeLaunchOverlay::Environment(overlay) => {
5231                control
5232                    .install_native_launch_environment_overlay(instance_id, overlay)
5233                    .map_err(|_| failure(
5234                        NodeFailureCode::BackendOperationFailed,
5235                        "session environment overlay installation failed",
5236                    ))?;
5237                Ok(None)
5238            }
5239            PreparedNativeLaunchOverlay::Instance(overlay) => {
5240                control
5241                    .install_native_instance_launch_overlay(instance_id, overlay)
5242                    .map_err(|_| failure(
5243                        NodeFailureCode::BundleBindingMismatch,
5244                        "session bundle launch overlay installation failed",
5245                    ))?;
5246                Ok(Some(NativeInstanceOverlayGuard {
5247                    control: Some(control),
5248                    instance_id,
5249                }))
5250            }
5251        }
5252    }
5253
5254    fn environment_materialization_profile(
5255        &self,
5256        environment_profile: &ResolvedEnvironmentProfileReceipt,
5257    ) -> Option<NodeSessionMaterializationProfile> {
5258        self.environment_materialization_profiles
5259            .read()
5260            .unwrap_or_else(|poisoned| poisoned.into_inner())
5261            .get(&environment_profile.profile_id)
5262            .cloned()
5263    }
5264
5265    fn materialization_profile(
5266        &self,
5267        provider: &AgentId,
5268        mode: SessionMode,
5269        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5270        bundle: Option<&ResolvedBundleReceipt>,
5271        context: Option<&ResolvedContextPackReceipt>,
5272    ) -> Result<Option<(
5273        NodeSessionMaterializationProfile,
5274        bool,
5275        Option<BundleProviderLayout>,
5276    )>, NodeFailure> {
5277        let environment = environment_profile
5278            .and_then(|receipt| self.environment_materialization_profile(receipt));
5279        let (bundle, bundle_layout) = match bundle {
5280            Some(receipt) => {
5281                let catalog = self.bundle_catalog.read()
5282                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5283                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5284                    NodeFailureCode::UnknownBundle,
5285                    "session bundle is unavailable on this node",
5286                ))?;
5287                if installed.receipt() != *receipt {
5288                    return Err(failure(
5289                        NodeFailureCode::BundleBindingMismatch,
5290                        "session bundle revision or digest changed",
5291                    ));
5292                }
5293                let layout = self.bundle_layout(
5294                    provider,
5295                    mode,
5296                    environment_profile,
5297                    installed,
5298                )?;
5299                (Some(installed.clone()), Some(layout))
5300            }
5301            None => (None, None),
5302        };
5303        let has_environment_overlay = environment.is_some() || context.is_some();
5304        let profile = match (environment, bundle.as_ref(), bundle_layout) {
5305            (None, None, None) => None,
5306            (Some(profile), None, None) => Some(Ok(profile)),
5307            (None, Some(bundle), Some(layout)) => {
5308                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5309            }
5310            (Some(profile), Some(bundle), Some(layout)) => {
5311                Some(profile.with_bundle(bundle, layout))
5312            }
5313            _ => {
5314                return Err(failure(
5315                    NodeFailureCode::BundleBindingMismatch,
5316                    "session bundle provider layout is unavailable",
5317                ));
5318            }
5319        };
5320        let profile = profile
5321            .transpose()
5322            .map_err(|_| failure(
5323                NodeFailureCode::BundleMaterializationFailed,
5324                "session bundle materialization profile is invalid",
5325            ))?;
5326        let profile = match (profile, context) {
5327            (Some(profile), Some(receipt)) => {
5328                let catalog = self.context_catalog
5329                    .read()
5330                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5331                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5332                    NodeFailureCode::UnknownContextPack,
5333                    "session context pack is unavailable on this node",
5334                ))?;
5335                if pack.receipt() != receipt {
5336                    return Err(failure(
5337                        NodeFailureCode::ContextPackMaterializationFailed,
5338                        "session context pack identity changed",
5339                    ));
5340                }
5341                profile.with_context(pack)
5342            }
5343            (None, 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                NodeSessionMaterializationProfile::from_context(pack)
5358            }
5359            (Some(profile), None) => Ok(profile),
5360            (None, None) => return Ok(None),
5361        }
5362        .map_err(|_| failure(
5363            NodeFailureCode::ContextPackMaterializationFailed,
5364            "session context pack materialization profile is invalid",
5365        ))?;
5366        Ok(Some((profile, has_environment_overlay, bundle_layout)))
5367    }
5368
5369    fn prepare_session_materialization<'a>(
5370        &'a self,
5371        address: &SessionAddress,
5372        provider: &AgentId,
5373        mode: SessionMode,
5374        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5375        bundle: Option<&ResolvedBundleReceipt>,
5376        context: Option<&ResolvedContextPackReceipt>,
5377        managed_lease_id: Option<ManagedWorktreeLeaseId>,
5378    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5379        let Some((profile, has_environment_overlay, bundle_layout)) =
5380            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5381        else {
5382            return Ok(None);
5383        };
5384        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5385            failure(
5386                NodeFailureCode::BackendOperationFailed,
5387                "session environment materializer is unavailable",
5388            )
5389        })?;
5390        let materialization_failure_code = if context.is_some() {
5391            NodeFailureCode::ContextPackMaterializationFailed
5392        } else if bundle.is_some() {
5393            NodeFailureCode::BundleMaterializationFailed
5394        } else {
5395            NodeFailureCode::BackendOperationFailed
5396        };
5397        let id = MaterializationId::new(format!(
5398            "mat-{}-{}-{}",
5399            self.incarnation_id,
5400            address.session.instance_id.0,
5401            address.session.generation.0,
5402        ))
5403        .map_err(|_| {
5404            failure(
5405                NodeFailureCode::BackendOperationFailed,
5406                "session environment materialization identity failed",
5407            )
5408        })?;
5409        let owner = MaterializationOwner::Session {
5410            incarnation_id: self.incarnation_id,
5411            instance_id: address.session.instance_id,
5412            generation: address.session.generation,
5413        };
5414        let mut ownership = materializer
5415            .begin(
5416                id.clone(),
5417                environment_profile.cloned(),
5418                bundle.cloned(),
5419                context.cloned(),
5420                owner,
5421                managed_lease_id,
5422                &profile,
5423                unix_time_ms(),
5424            )
5425            .map_err(|_| {
5426                failure(
5427                    materialization_failure_code,
5428                    "session materialization preparation failed",
5429                )
5430            })?;
5431        {
5432            let _transaction = self
5433                .state_transaction
5434                .lock()
5435                .unwrap_or_else(|poisoned| poisoned.into_inner());
5436            let duplicate = self
5437                .materializations
5438                .lock()
5439                .unwrap_or_else(|poisoned| poisoned.into_inner())
5440                .insert(id.clone(), ownership.clone());
5441            if duplicate.is_some() {
5442                return Err(failure(
5443                    NodeFailureCode::BackendOperationFailed,
5444                    "session environment materialization identity conflict",
5445                ));
5446            }
5447            if let Err(error) = self.persist_state_locked() {
5448                self.materializations
5449                    .lock()
5450                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5451                    .remove(&id);
5452                return Err(persistence_failure(error));
5453            }
5454        }
5455        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5456            Ok(environment) => environment,
5457            Err(_) => {
5458                let transaction = self
5459                    .state_transaction
5460                    .lock()
5461                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5462                self.materializations
5463                    .lock()
5464                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5465                    .insert(id.clone(), ownership.clone());
5466                if self.persist_state_locked().is_err() {
5467                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5468                }
5469                drop(transaction);
5470                if ownership.state() == MaterializationState::CleanupRequired {
5471                    let _ = self.cleanup_materialization(&id);
5472                }
5473                return Err(failure(
5474                    materialization_failure_code,
5475                    "session materialization failed",
5476                ));
5477            }
5478        };
5479        {
5480            let _transaction = self
5481                .state_transaction
5482                .lock()
5483                .unwrap_or_else(|poisoned| poisoned.into_inner());
5484            self.materializations
5485                .lock()
5486                .unwrap_or_else(|poisoned| poisoned.into_inner())
5487                .insert(id.clone(), ownership.clone());
5488            if let Err(error) = self.persist_state_locked() {
5489                drop(_transaction);
5490                let _ = self.cleanup_materialization(&id);
5491                return Err(persistence_failure(error));
5492            }
5493        }
5494        let guard = SessionMaterializationGuard {
5495            shared: self,
5496            id: Some(id),
5497            provider_home: Some(ownership.provider_home().to_path_buf()),
5498        };
5499        let bundle_arguments = match bundle {
5500            Some(receipt) => {
5501                let catalog = self.bundle_catalog.read()
5502                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5503                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5504                    NodeFailureCode::UnknownBundle,
5505                    "session bundle is unavailable on this node",
5506                ))?;
5507                if installed.receipt() != *receipt {
5508                    return Err(failure(
5509                        NodeFailureCode::BundleBindingMismatch,
5510                        "session bundle revision or digest changed",
5511                    ));
5512                }
5513                let layout = bundle_layout.ok_or_else(|| failure(
5514                    NodeFailureCode::BundleBindingMismatch,
5515                    "materialized bundle provider layout is unavailable",
5516                ))?;
5517                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5518                    failure(
5519                        NodeFailureCode::BundleMaterializationFailed,
5520                        "session bundle byte revalidation failed",
5521                    )
5522                })?;
5523                Some(bundle_launch_arguments(
5524                    layout,
5525                    ownership.bundle_root(),
5526                    ownership.provider_home(),
5527                ).map_err(|_| failure(
5528                    NodeFailureCode::BundleBindingMismatch,
5529                    "session bundle launch binding is invalid",
5530                ))?)
5531            }
5532            None => None,
5533        };
5534        if let Some(receipt) = context {
5535            materializer
5536                .revalidate_context(&ownership, receipt)
5537                .map_err(|_| failure(
5538                    NodeFailureCode::ContextPackMaterializationFailed,
5539                    "session context pack byte revalidation failed",
5540                ))?;
5541        }
5542        let transport = match mode {
5543            SessionMode::Pty => TransportKind::Pty,
5544            SessionMode::Inline => TransportKind::Pipe,
5545            SessionMode::Acp => TransportKind::Acp,
5546        };
5547        let overlay = if let Some(extra_args) = bundle_arguments {
5548            Some(PreparedNativeLaunchOverlay::Instance(
5549                NativeInstanceLaunchOverlay::new(
5550                    provider.clone(),
5551                    transport,
5552                    if has_environment_overlay {
5553                        environment
5554                    } else {
5555                        Vec::new()
5556                    },
5557                    extra_args,
5558                )
5559                .map_err(|_| {
5560                    failure(
5561                        NodeFailureCode::BundleBindingMismatch,
5562                        "session bundle launch overlay is invalid",
5563                    )
5564                })?,
5565            ))
5566        } else if has_environment_overlay {
5567            let context_only = environment_profile.is_none()
5568                && bundle.is_none()
5569                && context.is_some();
5570            let overlay = if context_only {
5571                NativeLaunchEnvironmentOverlay::new_context_root(
5572                    provider.clone(),
5573                    transport,
5574                    environment,
5575                )
5576                .map_err(|_| {
5577                    failure(
5578                        NodeFailureCode::ContextPackMaterializationFailed,
5579                        "context-only launch overlay is invalid",
5580                    )
5581                })?
5582            } else {
5583                NativeLaunchEnvironmentOverlay::new(
5584                    provider.clone(),
5585                    transport,
5586                    environment,
5587                )
5588                .map_err(|_| {
5589                    failure(
5590                        NodeFailureCode::BackendOperationFailed,
5591                        "session environment overlay is invalid",
5592                    )
5593                })?
5594            };
5595            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5596        } else {
5597            None
5598        };
5599        Ok(Some((overlay, guard)))
5600    }
5601
5602    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5603        let Some(mut ownership) = self
5604            .materializations
5605            .lock()
5606            .unwrap_or_else(|poisoned| poisoned.into_inner())
5607            .get(id)
5608            .cloned()
5609        else {
5610            return Ok(());
5611        };
5612        if ownership.state() != MaterializationState::CleanupRequired {
5613            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5614                failure(
5615                    NodeFailureCode::BackendOperationFailed,
5616                    "session environment cleanup state is invalid",
5617                )
5618            })?;
5619        }
5620        {
5621            let _transaction = self
5622                .state_transaction
5623                .lock()
5624                .unwrap_or_else(|poisoned| poisoned.into_inner());
5625            let previous = self
5626                .materializations
5627                .lock()
5628                .unwrap_or_else(|poisoned| poisoned.into_inner())
5629                .insert(id.clone(), ownership.clone());
5630            if let Err(error) = self.persist_state_locked() {
5631                let mut materializations = self
5632                    .materializations
5633                    .lock()
5634                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5635                if let Some(previous) = previous {
5636                    materializations.insert(id.clone(), previous);
5637                }
5638                return Err(persistence_failure(error));
5639            }
5640        }
5641        let cleanup = self
5642            .session_environment_materializer
5643            .as_ref()
5644            .ok_or(())
5645            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5646        if cleanup.is_err() {
5647            let _ = ownership.mark_recovery_required(unix_time_ms());
5648            self.materializations
5649                .lock()
5650                .unwrap_or_else(|poisoned| poisoned.into_inner())
5651                .insert(id.clone(), ownership);
5652            if self.persist_state().is_err() {
5653                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5654            }
5655            return Err(failure(
5656                NodeFailureCode::BackendOperationFailed,
5657                "session environment cleanup requires recovery",
5658            ));
5659        }
5660        let _transaction = self
5661            .state_transaction
5662            .lock()
5663            .unwrap_or_else(|poisoned| poisoned.into_inner());
5664        self.materializations
5665            .lock()
5666            .unwrap_or_else(|poisoned| poisoned.into_inner())
5667            .remove(id);
5668        if let Err(error) = self.persist_state_locked() {
5669            self.materializations
5670                .lock()
5671                .unwrap_or_else(|poisoned| poisoned.into_inner())
5672                .insert(id.clone(), ownership);
5673            return Err(persistence_failure(error));
5674        }
5675        Ok(())
5676    }
5677
5678    fn resolve_record_materialization(
5679        &self,
5680        record_id: &SessionRecordId,
5681        provider: &AgentId,
5682        mode: SessionMode,
5683        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5684        bundle: Option<&ResolvedBundleReceipt>,
5685        context: Option<&ResolvedContextPackReceipt>,
5686    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5687        let ownership = {
5688            let materializations = self
5689                .materializations
5690                .lock()
5691                .unwrap_or_else(|poisoned| poisoned.into_inner());
5692            let mut matches = materializations.values().filter(|record| {
5693                record.owner()
5694                    == &MaterializationOwner::Record {
5695                        record_id: record_id.clone(),
5696                    }
5697            });
5698            let ownership = matches.next().cloned();
5699            if matches.next().is_some() {
5700                return Err(failure(
5701                    NodeFailureCode::BackendOperationFailed,
5702                    "managed session has conflicting environment ownership",
5703                ));
5704            }
5705            ownership
5706        };
5707        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5708            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5709                failure(
5710                    NodeFailureCode::ContextPackMaterializationFailed,
5711                    "session context materializer is unavailable",
5712                )
5713            })?;
5714            let pack = materializer
5715                .revalidate_context(ownership, receipt)
5716                .map_err(|_| {
5717                    self.mark_materialization_recovery_required(ownership.id());
5718                    failure(
5719                        NodeFailureCode::ContextPackMaterializationFailed,
5720                        "managed session context pack revalidation failed",
5721                    )
5722                })?;
5723            self.context_catalog
5724                .write()
5725                .unwrap_or_else(|poisoned| poisoned.into_inner())
5726                .insert(pack)
5727                .map_err(|_| failure(
5728                    NodeFailureCode::ContextPackMaterializationFailed,
5729                    "managed session context pack catalog restore failed",
5730                ))?;
5731        }
5732        let materialization = self.materialization_profile(
5733            provider,
5734            mode,
5735            environment_profile,
5736            bundle,
5737            context,
5738        )?;
5739        match (ownership, materialization) {
5740            (None, None) => Ok(None),
5741            (None, Some(_)) | (Some(_), None) => Err(failure(
5742                NodeFailureCode::BackendOperationFailed,
5743                "managed session materialization is unavailable",
5744            )),
5745            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5746                if ownership.environment_profile() != environment_profile
5747                    || ownership.bundle() != bundle
5748                    || ownership.context() != context
5749                    || ownership.state() != MaterializationState::Ready
5750                {
5751                    self.mark_materialization_recovery_required(ownership.id());
5752                    return Err(failure(
5753                        NodeFailureCode::BackendOperationFailed,
5754                        "managed session environment ownership requires recovery",
5755                    ));
5756                }
5757                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5758                    failure(
5759                        NodeFailureCode::BackendOperationFailed,
5760                        "session environment materializer is unavailable",
5761                    )
5762                })?;
5763                if materializer.revalidate(&ownership).is_err() {
5764                    self.mark_materialization_recovery_required(ownership.id());
5765                    return Err(failure(
5766                        NodeFailureCode::BackendOperationFailed,
5767                        "managed session environment revalidation failed",
5768                    ));
5769                }
5770                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5771                    let catalog = self.bundle_catalog.read()
5772                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5773                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5774                        return Err(failure(
5775                            NodeFailureCode::UnknownBundle,
5776                            "managed session bundle is unavailable",
5777                        ));
5778                    };
5779                    let layout = bundle_layout.ok_or_else(|| failure(
5780                        NodeFailureCode::BundleBindingMismatch,
5781                        "record bundle provider layout is unavailable",
5782                    ))?;
5783                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5784                        drop(catalog);
5785                        self.mark_materialization_recovery_required(ownership.id());
5786                        return Err(failure(
5787                            NodeFailureCode::BundleMaterializationFailed,
5788                            "managed session bundle revalidation failed",
5789                        ));
5790                    }
5791                    Some(bundle_launch_arguments(
5792                        layout,
5793                        ownership.bundle_root(),
5794                        ownership.provider_home(),
5795                    ).map_err(|_| failure(
5796                        NodeFailureCode::BundleBindingMismatch,
5797                        "managed session bundle launch binding is invalid",
5798                    ))?)
5799                } else {
5800                    None
5801                };
5802                if !has_environment_materialization {
5803                    let overlay = bundle_arguments.map(|extra_args| {
5804                        NativeInstanceLaunchOverlay::new(
5805                            provider.clone(),
5806                            TransportKind::Pty,
5807                            Vec::new(),
5808                            extra_args,
5809                        )
5810                        .map(PreparedNativeLaunchOverlay::Instance)
5811                        .map_err(|_| failure(
5812                            NodeFailureCode::BundleBindingMismatch,
5813                            "managed session bundle launch overlay is invalid",
5814                        ))
5815                    }).transpose()?;
5816                    return Ok(Some((ownership.id().clone(), overlay)));
5817                }
5818                let environment = match materializer.resolve_environment(&ownership, &profile) {
5819                    Ok(environment) => environment,
5820                    Err(
5821                        SessionEnvironmentMaterializeError::SecretUnavailable
5822                        | SessionEnvironmentMaterializeError::SecretDenied
5823                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5824                    ) => {
5825                        return Err(failure(
5826                            NodeFailureCode::BackendOperationFailed,
5827                            "managed session environment resolution failed",
5828                        ));
5829                    }
5830                    Err(
5831                        SessionEnvironmentMaterializeError::InvalidRoot
5832                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5833                        | SessionEnvironmentMaterializeError::Filesystem(_)
5834                        | SessionEnvironmentMaterializeError::Record(_),
5835                    ) => {
5836                        self.mark_materialization_recovery_required(ownership.id());
5837                        return Err(failure(
5838                            NodeFailureCode::BackendOperationFailed,
5839                            "managed session environment resolution failed",
5840                        ));
5841                    }
5842                };
5843                let transport = match mode {
5844                    SessionMode::Pty => TransportKind::Pty,
5845                    SessionMode::Inline => TransportKind::Pipe,
5846                    SessionMode::Acp => TransportKind::Acp,
5847                };
5848                let overlay = match bundle_arguments {
5849                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5850                        NativeInstanceLaunchOverlay::new(
5851                            provider.clone(),
5852                            transport,
5853                            environment,
5854                            extra_args,
5855                        )
5856                        .map_err(|_| failure(
5857                            NodeFailureCode::BundleBindingMismatch,
5858                            "managed session bundle launch overlay is invalid",
5859                        ))?,
5860                    ),
5861                    None => PreparedNativeLaunchOverlay::Environment(
5862                        NativeLaunchEnvironmentOverlay::new(
5863                            provider.clone(),
5864                            transport,
5865                            environment,
5866                        )
5867                        .map_err(|_| {
5868                            failure(
5869                                NodeFailureCode::BackendOperationFailed,
5870                                "managed session environment overlay is invalid",
5871                            )
5872                        })?,
5873                    ),
5874                };
5875                Ok(Some((ownership.id().clone(), Some(overlay))))
5876            }
5877        }
5878    }
5879
5880    fn destroy_record_materialization(
5881        &self,
5882        record_id: &SessionRecordId,
5883    ) -> Result<bool, NodeFailure> {
5884        let ids = self
5885            .materializations
5886            .lock()
5887            .unwrap_or_else(|poisoned| poisoned.into_inner())
5888            .values()
5889            .filter_map(|record| {
5890                (record.owner()
5891                    == &MaterializationOwner::Record {
5892                        record_id: record_id.clone(),
5893                    })
5894                .then(|| record.id().clone())
5895            })
5896            .collect::<Vec<_>>();
5897        if ids.len() > 1 {
5898            return Err(failure(
5899                NodeFailureCode::BackendOperationFailed,
5900                "managed session has conflicting environment ownership",
5901            ));
5902        }
5903        let Some(id) = ids.first().cloned() else {
5904            return Ok(false);
5905        };
5906        let (terminal_record, mut cleanup_ownership) = {
5907            let _transaction = self
5908                .state_transaction
5909                .lock()
5910                .unwrap_or_else(|poisoned| poisoned.into_inner());
5911            let original_record = self.record(record_id)?;
5912            let mut terminal_record = original_record.clone();
5913            terminal_record.provider_session = None;
5914            terminal_record.active_session = None;
5915            terminal_record.state = ManagedSessionState::Unavailable;
5916            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5917            terminal_record.updated_at_unix_ms = unix_time_ms();
5918            let original_ownership = self
5919                .materializations
5920                .lock()
5921                .unwrap_or_else(|poisoned| poisoned.into_inner())
5922                .get(&id)
5923                .cloned()
5924                .ok_or_else(|| {
5925                    failure(
5926                        NodeFailureCode::BackendOperationFailed,
5927                        "managed session environment ownership disappeared during cleanup",
5928                    )
5929                })?;
5930            let mut cleanup_ownership = original_ownership.clone();
5931            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5932                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5933                    failure(
5934                        NodeFailureCode::BackendOperationFailed,
5935                        "managed session environment cleanup state is invalid",
5936                    )
5937                })?;
5938            }
5939            self.session_records
5940                .lock()
5941                .unwrap_or_else(|poisoned| poisoned.into_inner())
5942                .records
5943                .insert(record_id.clone(), terminal_record.clone());
5944            self.materializations
5945                .lock()
5946                .unwrap_or_else(|poisoned| poisoned.into_inner())
5947                .insert(id.clone(), cleanup_ownership.clone());
5948            if let Err(error) = self.persist_state_locked() {
5949                self.session_records
5950                    .lock()
5951                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5952                    .records
5953                    .insert(record_id.clone(), original_record);
5954                self.materializations
5955                    .lock()
5956                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5957                    .insert(id.clone(), original_ownership);
5958                return Err(persistence_failure(error));
5959            }
5960            (terminal_record, cleanup_ownership)
5961        };
5962        let cleanup = self
5963            .session_environment_materializer
5964            .as_ref()
5965            .ok_or(())
5966            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5967        if cleanup.is_err() {
5968            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5969            self.materializations
5970                .lock()
5971                .unwrap_or_else(|poisoned| poisoned.into_inner())
5972                .insert(id.clone(), cleanup_ownership);
5973            if self.persist_state().is_err() {
5974                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5975            }
5976            return Err(failure(
5977                NodeFailureCode::BackendOperationFailed,
5978                "managed session environment cleanup requires recovery",
5979            ));
5980        }
5981        let _transaction = self
5982            .state_transaction
5983            .lock()
5984            .unwrap_or_else(|poisoned| poisoned.into_inner());
5985        self.remove_record_memory(record_id);
5986        self.materializations
5987            .lock()
5988            .unwrap_or_else(|poisoned| poisoned.into_inner())
5989            .remove(&id);
5990        if let Err(error) = self.persist_state_locked() {
5991            self.session_records
5992                .lock()
5993                .unwrap_or_else(|poisoned| poisoned.into_inner())
5994                .records
5995                .insert(record_id.clone(), terminal_record);
5996            self.materializations
5997                .lock()
5998                .unwrap_or_else(|poisoned| poisoned.into_inner())
5999                .insert(id, cleanup_ownership);
6000            return Err(persistence_failure(error));
6001        }
6002        Ok(true)
6003    }
6004
6005    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
6006        let changed = {
6007            let mut materializations = self
6008                .materializations
6009                .lock()
6010                .unwrap_or_else(|poisoned| poisoned.into_inner());
6011            let Some(ownership) = materializations.get_mut(id) else {
6012                return;
6013            };
6014            ownership.mark_recovery_required(unix_time_ms()).is_ok()
6015        };
6016        if changed && self.persist_state().is_err() {
6017            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6018        }
6019    }
6020
6021    fn reconcile_materializations(&self) {
6022        let records = self
6023            .materializations
6024            .lock()
6025            .unwrap_or_else(|poisoned| poisoned.into_inner())
6026            .values()
6027            .cloned()
6028            .collect::<Vec<_>>();
6029        for mut ownership in records {
6030            let result = match (ownership.state(), ownership.owner()) {
6031                (MaterializationState::Preparing, _)
6032                | (MaterializationState::CleanupRequired, _) => {
6033                    self.cleanup_materialization(ownership.id())
6034                }
6035                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
6036                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
6037                        self.materializations
6038                            .lock()
6039                            .unwrap_or_else(|poisoned| poisoned.into_inner())
6040                            .insert(ownership.id().clone(), ownership.clone());
6041                        self.persist_state().map_err(persistence_failure)
6042                    } else {
6043                        Err(failure(
6044                            NodeFailureCode::BackendOperationFailed,
6045                            "session environment reconciliation failed",
6046                        ))
6047                    }
6048                }
6049                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
6050                    .session_environment_materializer
6051                    .as_ref()
6052                    .ok_or_else(|| {
6053                        failure(
6054                            NodeFailureCode::BackendOperationFailed,
6055                            "session environment materializer is unavailable",
6056                        )
6057                    })
6058                    .and_then(|materializer| {
6059                        materializer.revalidate(&ownership).map_err(|_| failure(
6060                            NodeFailureCode::BackendOperationFailed,
6061                            "session materialization revalidation failed",
6062                        ))?;
6063                        let record = self.record(record_id)?;
6064                        if record.environment_profile.as_ref() != ownership.environment_profile()
6065                            || record.bundle.as_ref() != ownership.bundle()
6066                            || record.context.as_ref() != ownership.context()
6067                        {
6068                            return Err(failure(
6069                                NodeFailureCode::BackendOperationFailed,
6070                                "session materialization ownership changed after restart",
6071                            ));
6072                        }
6073                        if let Some(receipt) = ownership.bundle() {
6074                            let catalog = self.bundle_catalog.read()
6075                                .unwrap_or_else(|poisoned| poisoned.into_inner());
6076                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
6077                                NodeFailureCode::UnknownBundle,
6078                                "session bundle is unavailable after restart",
6079                            ))?;
6080                            if bundle.receipt() != *receipt {
6081                                return Err(failure(
6082                                    NodeFailureCode::BundleBindingMismatch,
6083                                    "session bundle changed after restart",
6084                                ));
6085                            }
6086                            let layout = self.bundle_layout(
6087                                &record.provider,
6088                                record.mode,
6089                                record.environment_profile.as_ref(),
6090                                bundle,
6091                            )?;
6092                            materializer
6093                                .revalidate_bundle(&ownership, bundle, layout)
6094                                .map_err(|_| failure(
6095                                    NodeFailureCode::BundleMaterializationFailed,
6096                                    "session bundle revalidation failed",
6097                                ))?;
6098                        }
6099                        if let Some(receipt) = ownership.context() {
6100                            let pack = materializer
6101                                .revalidate_context(&ownership, receipt)
6102                                .map_err(|_| {
6103                                    failure(
6104                                        NodeFailureCode::ContextPackMaterializationFailed,
6105                                        "session context revalidation failed",
6106                                    )
6107                                })?;
6108                            self.context_catalog
6109                                .write()
6110                                .unwrap_or_else(|poisoned| poisoned.into_inner())
6111                                .insert(pack)
6112                                .map_err(|_| {
6113                                    failure(
6114                                        NodeFailureCode::ContextPackMaterializationFailed,
6115                                        "session context catalog restore failed",
6116                                    )
6117                                })?;
6118                        }
6119                        Ok(())
6120                    }),
6121                (MaterializationState::RecoveryRequired, _) => Ok(()),
6122            };
6123            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
6124                let _ = ownership.mark_recovery_required(unix_time_ms());
6125                self.materializations
6126                    .lock()
6127                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6128                    .insert(ownership.id().clone(), ownership);
6129                if self.persist_state().is_err() {
6130                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6131                }
6132            }
6133        }
6134        let materialized_profiles = self
6135            .environment_materialization_profiles
6136            .read()
6137            .unwrap_or_else(|poisoned| poisoned.into_inner())
6138            .keys()
6139            .cloned()
6140            .collect::<Vec<_>>();
6141        let ownership = self
6142            .materializations
6143            .lock()
6144            .unwrap_or_else(|poisoned| poisoned.into_inner())
6145            .values()
6146            .cloned()
6147            .collect::<Vec<_>>();
6148        let changed = {
6149            let mut session_records = self
6150                .session_records
6151                .lock()
6152                .unwrap_or_else(|poisoned| poisoned.into_inner());
6153            let mut changed = false;
6154            for record in session_records.records.values_mut() {
6155                let environment_profile = record.environment_profile.as_ref();
6156                if environment_profile.is_none()
6157                    && record.bundle.is_none()
6158                    && record.context.is_none()
6159                {
6160                    continue;
6161                }
6162                if record.bundle.is_none()
6163                    && environment_profile.is_some_and(|profile| {
6164                        !materialized_profiles.contains(&profile.profile_id)
6165                    })
6166                {
6167                    continue;
6168                }
6169                let has_exact_ownership = ownership.iter().any(|candidate| {
6170                    candidate.state() == MaterializationState::Ready
6171                        && candidate.environment_profile() == environment_profile
6172                        && candidate.bundle() == record.bundle.as_ref()
6173                        && candidate.context() == record.context.as_ref()
6174                        && candidate.owner()
6175                            == &MaterializationOwner::Record {
6176                                record_id: record.record_id.clone(),
6177                            }
6178                });
6179                if !has_exact_ownership {
6180                    record.provider_session = None;
6181                    record.active_session = None;
6182                    record.state = ManagedSessionState::Unavailable;
6183                    record.last_error = Some(if record.bundle.is_some() {
6184                        "bundle-unavailable".to_owned()
6185                    } else if record.context.is_some() {
6186                        "context-unavailable".to_owned()
6187                    } else {
6188                        "environment-profile-unavailable".to_owned()
6189                    });
6190                    record.updated_at_unix_ms = unix_time_ms();
6191                    changed = true;
6192                }
6193            }
6194            changed
6195        };
6196        if changed && self.persist_state().is_err() {
6197            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6198        }
6199    }
6200
6201    fn replay_spawn_spec(
6202        &self,
6203        spec: &SpawnSpec,
6204        now: Instant,
6205    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
6206        let mut cache = self
6207            .spawn_idempotency
6208            .lock()
6209            .unwrap_or_else(|poisoned| poisoned.into_inner());
6210        cache.entries.retain(|_, entry| entry.expires_at > now);
6211        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
6212            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
6213                return Err(failure(
6214                    NodeFailureCode::SpawnIdempotencyConflict,
6215                    "spawn idempotency key was reused across spawn kinds",
6216                ));
6217            };
6218            if existing != spec {
6219                return Err(failure(
6220                    NodeFailureCode::SpawnIdempotencyConflict,
6221                    "spawn idempotency key was reused with a different specification",
6222                ));
6223            }
6224            return Ok(Some(result.clone()));
6225        }
6226        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6227            return Err(failure(
6228                NodeFailureCode::SpawnIdempotencyCapacity,
6229                "spawn idempotency cache reached its bounded capacity",
6230            ));
6231        }
6232        Ok(None)
6233    }
6234
6235    fn remember_spawn_spec(
6236        &self,
6237        spec: SpawnSpec,
6238        result: Result<ResolvedSpawnReceipt, NodeFailure>,
6239        accepted_at: Instant,
6240    ) {
6241        self
6242            .spawn_idempotency
6243            .lock()
6244            .unwrap_or_else(|poisoned| poisoned.into_inner())
6245            .entries
6246            .insert(
6247                spec.idempotency_key.clone(),
6248                SpawnIdempotencyEntry {
6249                    value: SpawnIdempotencyValue::Standard { spec, result },
6250                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6251                },
6252            );
6253    }
6254
6255    fn claim_harness_mcp_spawn(
6256        &self,
6257        _reservation_id: &HarnessMcpReservationId,
6258        _activation_digest: &HarnessMcpActivationDigest,
6259        spec: &SpawnSpec,
6260        now: Instant,
6261    ) -> Result<(), NodeFailure> {
6262        let mut cache = self.spawn_idempotency.lock()
6263            .unwrap_or_else(|poisoned| poisoned.into_inner());
6264        cache.entries.retain(|_, entry| entry.expires_at > now);
6265        if cache.entries.contains_key(&spec.idempotency_key) {
6266            return Err(failure(
6267                NodeFailureCode::SpawnIdempotencyConflict,
6268                "spawn idempotency key was reused across spawn kinds",
6269            ));
6270        }
6271        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6272            return Err(failure(
6273                NodeFailureCode::SpawnIdempotencyCapacity,
6274                "spawn idempotency cache reached its bounded capacity",
6275            ));
6276        }
6277        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
6278            value: SpawnIdempotencyValue::HarnessMcp,
6279            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6280        });
6281        Ok(())
6282    }
6283
6284    fn replay_managed_spawn(
6285        &self,
6286        request: &ManagedWorktreeSpawnRequest,
6287        now: Instant,
6288    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6289        let mut cache = self.spawn_idempotency.lock()
6290            .unwrap_or_else(|poisoned| poisoned.into_inner());
6291        cache.entries.retain(|_, entry| entry.expires_at > now);
6292        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6293            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6294                return Err(failure(
6295                    NodeFailureCode::SpawnIdempotencyConflict,
6296                    "spawn idempotency key was reused across spawn kinds",
6297                ));
6298            };
6299            if existing != request {
6300                return Err(failure(
6301                    NodeFailureCode::SpawnIdempotencyConflict,
6302                    "spawn idempotency key was reused with a different managed specification",
6303                ));
6304            }
6305            return Ok(Some(result.clone()));
6306        }
6307        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6308            return Err(failure(
6309                NodeFailureCode::SpawnIdempotencyCapacity,
6310                "spawn idempotency cache reached its bounded capacity",
6311            ));
6312        }
6313        Ok(None)
6314    }
6315
6316    fn remember_managed_spawn(
6317        &self,
6318        request: ManagedWorktreeSpawnRequest,
6319        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6320        accepted_at: Instant,
6321    ) {
6322        self.spawn_idempotency.lock()
6323            .unwrap_or_else(|poisoned| poisoned.into_inner())
6324            .entries
6325            .insert(
6326                request.spawn_spec.idempotency_key.clone(),
6327                SpawnIdempotencyEntry {
6328                    value: SpawnIdempotencyValue::Managed { request, result },
6329                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6330                },
6331            );
6332    }
6333
6334    fn remember_managed_spawn_attempt(
6335        &self,
6336        durable_v2_key: Option<&SpawnIdempotencyKey>,
6337        request: ManagedWorktreeSpawnRequest,
6338        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6339        accepted_at: Instant,
6340    ) {
6341        if durable_v2_key.is_none() {
6342            self.remember_managed_spawn(request, result, accepted_at);
6343        }
6344    }
6345
6346    fn acquire_durable_managed_spawn_v2(
6347        &self,
6348        request: &ManagedWorktreeSpawnRequestV2,
6349        request_digest: String,
6350    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6351        if self.state_path.is_none() {
6352            return Err(failure(
6353                NodeFailureCode::BackendOperationFailed,
6354                "managed worktree V2 requires durable node state",
6355            ));
6356        }
6357        let _transaction = self.state_transaction
6358            .lock()
6359            .unwrap_or_else(|poisoned| poisoned.into_inner());
6360        let key = request.spawn_spec.idempotency_key.clone();
6361        let mut previous = None;
6362        {
6363            let mut replays = self.managed_spawn_replays
6364                .lock()
6365                .unwrap_or_else(|poisoned| poisoned.into_inner());
6366            if let Some(record) = replays.get_mut(&key) {
6367                if record.request_digest != request_digest {
6368                    return Err(failure(
6369                        NodeFailureCode::SpawnIdempotencyConflict,
6370                        "managed worktree V2 idempotency key was reused with a different request",
6371                    ));
6372                }
6373                match &record.state {
6374                    ManagedWorktreeSpawnReplayStateV10::Pending {
6375                        owner_incarnation_id,
6376                        lease_id: None,
6377                    } if owner_incarnation_id != &self.incarnation_id => {
6378                        previous = Some(record.clone());
6379                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6380                            owner_incarnation_id: self.incarnation_id,
6381                            lease_id: None,
6382                        };
6383                        record.updated_at_unix_ms = unix_time_ms();
6384                    }
6385                    ManagedWorktreeSpawnReplayStateV10::Pending {
6386                        lease_id: None,
6387                        ..
6388                    } => {
6389                        return Err(failure(
6390                            NodeFailureCode::BackendBusy,
6391                            "managed worktree V2 reservation is already active",
6392                        ));
6393                    }
6394                    ManagedWorktreeSpawnReplayStateV10::Pending {
6395                        lease_id: Some(_),
6396                        ..
6397                    } => {
6398                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6399                    }
6400                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6401                        return Ok(DurableManagedSpawnReplayDecision::Committed(
6402                            receipt.clone(),
6403                        ));
6404                    }
6405                }
6406            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6407                return Err(failure(
6408                    NodeFailureCode::SpawnIdempotencyCapacity,
6409                    "durable managed worktree replay ledger reached its bounded capacity",
6410                ));
6411            } else {
6412                let now = unix_time_ms();
6413                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6414                    idempotency_key: key.clone(),
6415                    request_digest,
6416                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6417                    profile_id: request.worktree_profile_id.clone(),
6418                    expected_profile_revision: request.expected_profile_revision.clone(),
6419                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
6420                        owner_incarnation_id: self.incarnation_id,
6421                        lease_id: None,
6422                    },
6423                    created_at_unix_ms: now,
6424                    updated_at_unix_ms: now,
6425                });
6426            }
6427        }
6428        if let Err(error) = self.persist_state_locked() {
6429            let mut replays = self.managed_spawn_replays
6430                .lock()
6431                .unwrap_or_else(|poisoned| poisoned.into_inner());
6432            match previous {
6433                Some(previous) => {
6434                    replays.insert(key, previous);
6435                }
6436                None => {
6437                    replays.remove(&key);
6438                }
6439            }
6440            return Err(persistence_failure(error));
6441        }
6442        Ok(DurableManagedSpawnReplayDecision::Reserved)
6443    }
6444
6445    fn link_durable_managed_spawn_v2_lease_locked(
6446        &self,
6447        key: &SpawnIdempotencyKey,
6448        lease_id: &ManagedWorktreeLeaseId,
6449    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6450        let mut replays = self.managed_spawn_replays
6451            .lock()
6452            .unwrap_or_else(|poisoned| poisoned.into_inner());
6453        let record = replays.get_mut(key).ok_or_else(|| {
6454            failure(
6455                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6456                "managed worktree V2 reservation disappeared before allocation",
6457            )
6458        })?;
6459        let previous = record.clone();
6460        match &mut record.state {
6461            ManagedWorktreeSpawnReplayStateV10::Pending {
6462                lease_id: current,
6463                ..
6464            }
6465                if current.is_none() =>
6466            {
6467                *current = Some(lease_id.clone());
6468                record.updated_at_unix_ms = unix_time_ms();
6469                Ok(previous)
6470            }
6471            _ => Err(failure(
6472                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6473                "managed worktree V2 reservation is not allocation-ready",
6474            )),
6475        }
6476    }
6477
6478    fn restore_durable_managed_spawn_v2_locked(
6479        &self,
6480        key: &SpawnIdempotencyKey,
6481        previous: ManagedWorktreeSpawnReplayRecordV10,
6482    ) {
6483        self.managed_spawn_replays
6484            .lock()
6485            .unwrap_or_else(|poisoned| poisoned.into_inner())
6486            .insert(key.clone(), previous);
6487    }
6488
6489    fn commit_durable_managed_spawn_v2(
6490        &self,
6491        request: &ManagedWorktreeSpawnRequestV2,
6492        receipt: ManagedWorktreeSpawnReceipt,
6493    ) -> Result<(), NodeFailure> {
6494        let _transaction = self.state_transaction
6495            .lock()
6496            .unwrap_or_else(|poisoned| poisoned.into_inner());
6497        let key = &request.spawn_spec.idempotency_key;
6498        let previous = {
6499            let mut replays = self.managed_spawn_replays
6500                .lock()
6501                .unwrap_or_else(|poisoned| poisoned.into_inner());
6502            let record = replays.get_mut(key).ok_or_else(|| {
6503                failure(
6504                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6505                    "managed worktree V2 reservation disappeared before commit",
6506                )
6507            })?;
6508            let previous = record.clone();
6509            match &record.state {
6510                ManagedWorktreeSpawnReplayStateV10::Pending {
6511                    lease_id: Some(lease_id),
6512                    ..
6513                } if lease_id == &receipt.lease.lease_id => {}
6514                _ => {
6515                    return Err(failure(
6516                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
6517                        "managed worktree V2 reservation is not linked to the spawned lease",
6518                    ));
6519                }
6520            }
6521            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6522            record.updated_at_unix_ms = unix_time_ms();
6523            previous
6524        };
6525        if let Err(error) = self.persist_state_locked() {
6526            self.restore_durable_managed_spawn_v2_locked(key, previous);
6527            return Err(persistence_failure(error));
6528        }
6529        Ok(())
6530    }
6531
6532    fn remove_unallocated_durable_managed_spawn_v2(
6533        &self,
6534        request: &ManagedWorktreeSpawnRequestV2,
6535    ) -> Result<(), NodeFailure> {
6536        let _transaction = self.state_transaction
6537            .lock()
6538            .unwrap_or_else(|poisoned| poisoned.into_inner());
6539        let key = &request.spawn_spec.idempotency_key;
6540        let removed = {
6541            let mut replays = self.managed_spawn_replays
6542                .lock()
6543                .unwrap_or_else(|poisoned| poisoned.into_inner());
6544            match replays.get(key).map(|record| &record.state) {
6545                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6546                    lease_id: None,
6547                    ..
6548                }) => {
6549                    replays.remove(key)
6550                }
6551                _ => None,
6552            }
6553        };
6554        let Some(removed) = removed else {
6555            return Ok(());
6556        };
6557        if let Err(error) = self.persist_state_locked() {
6558            self.managed_spawn_replays
6559                .lock()
6560                .unwrap_or_else(|poisoned| poisoned.into_inner())
6561                .insert(key.clone(), removed);
6562            return Err(persistence_failure(error));
6563        }
6564        Ok(())
6565    }
6566
6567    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6568        if spec.target.node_id != self.node_id {
6569            return Err(failure(
6570                NodeFailureCode::SpawnTargetMismatch,
6571                "spawn target does not match this node",
6572            ));
6573        }
6574        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6575            failure(
6576                NodeFailureCode::UnknownSpawnProfile,
6577                "spawn profile is unavailable on this node",
6578            )
6579        })?;
6580        let resolved = spec.resolve(defaults).map_err(|error| match error {
6581            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6582                NodeFailureCode::SpawnProfileRevisionMismatch,
6583                "spawn profile revision does not match the loaded profile",
6584            ),
6585            _ => failure(
6586                NodeFailureCode::InvalidRequest,
6587                "spawn specification could not be resolved",
6588            ),
6589        })?;
6590        if resolved.target.worktree_id.is_some() {
6591            self.require_worktree_service(&resolved.target.workspace_id)?;
6592        }
6593        let environment_profile = self.resolve_environment_profile(&resolved)?;
6594        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6595        self.resolve_context(&resolved)?;
6596        Ok(resolved)
6597    }
6598
6599    async fn resolve_spawn_workspace(
6600        &self,
6601        resolved: &ResolvedSpawnSpec,
6602        deadline: Instant,
6603    ) -> Result<WorkspaceId, NodeFailure> {
6604        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6605            return Ok(resolved.target.workspace_id.clone());
6606        };
6607        if self.managed_worktrees.lock()
6608            .unwrap_or_else(|poisoned| poisoned.into_inner())
6609            .lease_for_workspace(worktree_id)
6610            .is_some()
6611        {
6612            return Err(failure(
6613                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6614                "managed worktree targets may only be spawned through their lease request",
6615            ));
6616        }
6617        self.require_worktree_service(&resolved.target.workspace_id)?;
6618        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6619        let selected_root = self.workspace_root(worktree_id)?;
6620        let remaining = spawn_deadline_remaining(deadline)?;
6621        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6622            .await
6623            .map_err(|_| {
6624                failure(
6625                    NodeFailureCode::SpawnDeadlineExceeded,
6626                    "Git worktree selection exceeded the spawn deadline",
6627                )
6628            })?
6629            .map_err(git_worktree_failure)?;
6630        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6631        Ok(worktree_id.clone())
6632    }
6633
6634    async fn spawn_from_spec(
6635        &self,
6636        spec: SpawnSpec,
6637    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6638        self.spawn_from_spec_with_proxy(spec, None).await
6639    }
6640
6641    async fn spawn_from_spec_with_proxy(
6642        &self,
6643        spec: SpawnSpec,
6644        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6645    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6646        let accepted_at = Instant::now();
6647        if harness_mcp.is_none() {
6648            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6649                return replayed;
6650            }
6651        }
6652        let resolved = self.resolve_spawn_spec(&spec)?;
6653        let environment_profile = self.resolve_environment_profile(&resolved)?;
6654        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6655        let context = self.resolve_context(&resolved)?;
6656        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6657        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6658        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6659        let spawn_workspace_id = match spawn_workspace_id {
6660            Ok(workspace_id) => workspace_id,
6661            Err(error) => {
6662                let result = Err(error);
6663                if harness_mcp.is_none() {
6664                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6665                }
6666                return result;
6667            }
6668        };
6669        if self.managed_worktrees.lock()
6670            .unwrap_or_else(|poisoned| poisoned.into_inner())
6671            .lease_for_workspace(&spawn_workspace_id)
6672            .is_some()
6673        {
6674            let result = Err(failure(
6675                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6676                "managed worktree targets may only be spawned through their lease request",
6677            ));
6678            if harness_mcp.is_none() {
6679                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6680            }
6681            return result;
6682        }
6683        let result = self
6684            .spawn_session_with_deadline(
6685                spawn_workspace_id,
6686                resolved.provider.clone(),
6687                resolved.mode,
6688                resolved.terminal_size,
6689                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6690                environment_profile.clone(),
6691                bundle.clone(),
6692                context.clone(),
6693                None,
6694                None,
6695                SpawnRecordPolicy::Always,
6696                Some(deadline),
6697                &required_capabilities,
6698                harness_mcp,
6699                resolved.approval_level,
6700                resolved.network_allowlist.clone(),
6701                resolved.browser_profile_id.clone(),
6702            )
6703            .await
6704            .map(|(session, _runtime_policy)| {
6705                resolved.receipt_with_materialization(
6706                    self.incarnation_id,
6707                    session,
6708                    environment_profile,
6709                    bundle,
6710                    context,
6711                )
6712            });
6713        if harness_mcp.is_none() {
6714            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6715        }
6716        result
6717    }
6718
6719    async fn spawn_from_spec_with_harness_mcp(
6720        &self,
6721        reservation_id: HarnessMcpReservationId,
6722        activation_digest: HarnessMcpActivationDigest,
6723        spec: SpawnSpec,
6724        deadline_unix_ms: u64,
6725    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6726        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6727            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6728        })?;
6729        if let Some(receipt) = registry
6730            .replay_spawn(&reservation_id, &activation_digest, &spec)
6731            .map_err(harness_mcp_failure)?
6732        {
6733            return Ok(receipt);
6734        }
6735        let resolved = self.resolve_spawn_spec(&spec)?;
6736        let prepared = registry
6737            .prepare_spawn(
6738                &reservation_id,
6739                &activation_digest,
6740                &spec,
6741                deadline_unix_ms,
6742                resolved.provider.clone(),
6743            )
6744            .map_err(harness_mcp_failure)?;
6745        self.claim_harness_mcp_spawn(
6746            &reservation_id,
6747            &activation_digest,
6748            &spec,
6749            Instant::now(),
6750        )?;
6751        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6752        let mut receipt = match spawn {
6753            Ok(receipt) => receipt,
6754            Err(error) => {
6755                let _ = registry.abort(&reservation_id, &activation_digest);
6756                return Err(error);
6757            }
6758        };
6759        let record_id = self
6760            .session_bindings
6761            .lock()
6762            .unwrap_or_else(|poisoned| poisoned.into_inner())
6763            .get(&receipt.session.session.instance_id)
6764            .and_then(|binding| binding.record_id.clone());
6765        let record_id = match record_id {
6766            Some(record_id) => record_id,
6767            None => {
6768                let rollback = self.remove_session(&receipt.session).await;
6769                let _ = registry.abort(&reservation_id, &activation_digest);
6770                if let Err(rollback_error) = rollback {
6771                    return Err(rollback_error);
6772                }
6773                return Err(failure(
6774                    NodeFailureCode::BindingMismatch,
6775                    "harness MCP spawn did not retain its exact managed record",
6776                ));
6777            }
6778        };
6779        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6780            reservation_id: reservation_id.clone(),
6781            activation_digest: activation_digest.clone(),
6782        });
6783        if let Err(error) = registry.mark_spawned(
6784            &prepared,
6785            receipt.session.clone(),
6786            record_id,
6787            receipt.clone(),
6788        ) {
6789            let rollback = self.remove_session(&receipt.session).await;
6790            let _ = registry.abort(&reservation_id, &activation_digest);
6791            if let Err(rollback_error) = rollback {
6792                return Err(rollback_error);
6793            }
6794            return Err(harness_mcp_failure(error));
6795        }
6796        Ok(receipt)
6797    }
6798
6799    async fn spawn_managed_worktree(
6800        &self,
6801        request: ManagedWorktreeSpawnRequest,
6802    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6803        self.spawn_managed_worktree_inner(request, None).await
6804    }
6805
6806    async fn spawn_managed_worktree_inner(
6807        &self,
6808        request: ManagedWorktreeSpawnRequest,
6809        durable_v2_key: Option<&SpawnIdempotencyKey>,
6810    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6811        let accepted_at = Instant::now();
6812        if durable_v2_key.is_none() {
6813            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6814                return replayed;
6815            }
6816        }
6817        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6818        if request.spawn_spec.target.worktree_id.is_some() {
6819            let result = Err(failure(
6820                NodeFailureCode::InvalidRequest,
6821                "managed worktree spawn must not select a caller-provided worktree",
6822            ));
6823            self.remember_managed_spawn_attempt(
6824                durable_v2_key,
6825                request,
6826                result.clone(),
6827                accepted_at,
6828            );
6829            return result;
6830        }
6831        let environment_profile = self.resolve_environment_profile(&resolved)?;
6832        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6833        let context = self.resolve_context(&resolved)?;
6834        let source_workspace_id = resolved.target.workspace_id.clone();
6835        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6836        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6837        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6838        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6839            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6840            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6841            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6842            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6843        };
6844        let runtime_policy = timeout(
6845            spawn_deadline_remaining(deadline)?,
6846            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6847        ).await.map_err(|_| failure(
6848            NodeFailureCode::SpawnDeadlineExceeded,
6849            "provider admission exceeded the managed spawn deadline",
6850        ))??;
6851        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6852            return Err(failure(
6853                NodeFailureCode::UnsupportedSpawnCapability,
6854                "provider runtime does not admit a required spawn capability",
6855            ));
6856        }
6857        self.ensure_binding_capacity()?;
6858        let source_root = self.workspace_root(&source_workspace_id)?;
6859        profile.validate_for_workspace(&source_root)
6860            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6861        let base_timeout_ms = u64::try_from(
6862            spawn_deadline_remaining(deadline)?.as_millis(),
6863        ).unwrap_or(u64::MAX).max(1);
6864        let base_commit = resolve_base_commit_with_timeout(
6865            &source_root,
6866            profile.base(),
6867            base_timeout_ms,
6868        ).await.map_err(|error| {
6869            if Instant::now() >= deadline || error.message.contains("timed out") {
6870                failure(
6871                    NodeFailureCode::SpawnDeadlineExceeded,
6872                    "managed worktree base resolution exceeded the spawn deadline",
6873                )
6874            } else {
6875                managed_git_worktree_failure(error)
6876            }
6877        })?;
6878
6879        let lease = {
6880            let _transaction = self.state_transaction.lock()
6881                .unwrap_or_else(|poisoned| poisoned.into_inner());
6882            let previous = self.managed_worktrees.lock()
6883                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6884            let lease = self.managed_worktrees.lock()
6885                .unwrap_or_else(|poisoned| poisoned.into_inner())
6886                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6887                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6888            let previous_replay = match durable_v2_key
6889                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6890                .transpose()
6891            {
6892                Ok(previous) => previous,
6893                Err(error) => {
6894                    *self.managed_worktrees.lock()
6895                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6896                    return Err(error);
6897                }
6898            };
6899            if let Err(error) = self.persist_state_locked() {
6900                *self.managed_worktrees.lock()
6901                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6902                if let (Some(key), Some(previous_replay)) =
6903                    (durable_v2_key, previous_replay)
6904                {
6905                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6906                }
6907                return Err(persistence_failure(error));
6908            }
6909            lease
6910        };
6911        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6912        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6913            self.managed_worktrees.lock()
6914                .unwrap_or_else(|poisoned| poisoned.into_inner())
6915                .remove_unmutated(&lease.lease_id);
6916            if self.persist_state().is_err() {
6917                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6918            }
6919            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6920            self.remember_managed_spawn_attempt(
6921                durable_v2_key,
6922                request,
6923                result.clone(),
6924                accepted_at,
6925            );
6926            return result;
6927        }
6928        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6929            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6930            Err(error) => {
6931                self.reconcile_failed_allocation(&lease, &source_root).await;
6932                let result = Err(error);
6933                self.remember_managed_spawn_attempt(
6934                    durable_v2_key,
6935                    request,
6936                    result.clone(),
6937                    accepted_at,
6938                );
6939                return result;
6940            }
6941        };
6942        let created = match create_git_worktree_with_timeout(
6943                &source_root,
6944                &lease.target_root,
6945                &lease.branch,
6946                Some(&base_commit),
6947                mutation_timeout_ms,
6948            ).await {
6949            Ok(created) => created,
6950            Err(error) => {
6951                self.reconcile_failed_allocation(&lease, &source_root).await;
6952                let result = Err(if Instant::now() >= deadline
6953                    || error.message.contains("timed out")
6954                    || error.message.contains("deadline")
6955                {
6956                    failure(
6957                        NodeFailureCode::SpawnDeadlineExceeded,
6958                        "managed worktree creation exceeded the spawn deadline",
6959                    )
6960                } else {
6961                    managed_git_worktree_failure(error)
6962                });
6963                self.remember_managed_spawn_attempt(
6964                    durable_v2_key,
6965                    request,
6966                    result.clone(),
6967                    accepted_at,
6968                );
6969                return result;
6970            }
6971        };
6972        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6973            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6974            let result = Err(failure(
6975                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6976                "created worktree identity did not match its durable lease",
6977            ));
6978            self.remember_managed_spawn_attempt(
6979                durable_v2_key,
6980                request,
6981                result.clone(),
6982                accepted_at,
6983            );
6984            return result;
6985        }
6986        {
6987            let mut registry = self.managed_worktrees.lock()
6988                .unwrap_or_else(|poisoned| poisoned.into_inner());
6989            let current = registry.get_mut(&lease.lease_id)
6990                .expect("allocated managed worktree lease remains present");
6991            current.expected_head = Some(created.head.clone());
6992            current.state = ManagedWorktreeLeaseState::Ready;
6993            current.updated_at_unix_ms = unix_time_ms();
6994        }
6995        if let Err(error) = self.persist_state() {
6996            self.mark_managed_recovery_required(
6997                &lease.lease_id,
6998                ManagedWorktreeCleanupFailure::Backend,
6999            );
7000            let result = Err(persistence_failure(error));
7001            self.remember_managed_spawn_attempt(
7002                durable_v2_key,
7003                request,
7004                result.clone(),
7005                accepted_at,
7006            );
7007            return result;
7008        }
7009        let ready = self.managed_worktrees.lock()
7010            .unwrap_or_else(|poisoned| poisoned.into_inner())
7011            .get(&lease.lease_id)
7012            .expect("ready managed worktree lease remains present")
7013            .snapshot();
7014        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
7015        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
7016            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7017            let result = Err(error);
7018            self.remember_managed_spawn_attempt(
7019                durable_v2_key,
7020                request,
7021                result.clone(),
7022                accepted_at,
7023            );
7024            return result;
7025        }
7026        let spawn = self.spawn_session_with_deadline(
7027            lease.workspace_id.clone(),
7028            resolved.provider.clone(),
7029            resolved.mode,
7030            resolved.terminal_size,
7031            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
7032            environment_profile.clone(),
7033            bundle.clone(),
7034            context.clone(),
7035            Some(lease.lease_id.clone()),
7036            Some(runtime_policy),
7037            SpawnRecordPolicy::Always,
7038            Some(deadline),
7039            &required_capabilities,
7040            None,
7041            resolved.approval_level,
7042            resolved.network_allowlist.clone(),
7043            resolved.browser_profile_id.clone(),
7044        ).await;
7045        let (session, effective_runtime_policy) = match spawn {
7046            Ok(spawned) => spawned,
7047            Err(error) => {
7048                if self
7049                    .cleanup_managed_worktree(&lease.lease_id, true)
7050                    .await
7051                    .is_err()
7052                {
7053                    self.mark_managed_recovery_required(
7054                        &lease.lease_id,
7055                        ManagedWorktreeCleanupFailure::Backend,
7056                    );
7057                }
7058                let result = Err(error);
7059                self.remember_managed_spawn_attempt(
7060                    durable_v2_key,
7061                    request,
7062                    result.clone(),
7063                    accepted_at,
7064                );
7065                return result;
7066            }
7067        };
7068        let (record_id, raw_inventory_record_id) = {
7069            let mut bindings = self.session_bindings.lock()
7070                .unwrap_or_else(|poisoned| poisoned.into_inner());
7071            let binding = bindings.get_mut(&session.session.instance_id)
7072                .expect("successful spawn retains its session binding");
7073            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
7074            managed_spawn_record_ownership(binding, effective_runtime_policy)
7075        };
7076        let bound = self.managed_worktrees.lock()
7077            .unwrap_or_else(|poisoned| poisoned.into_inner())
7078            .bind_session(
7079                &lease.lease_id,
7080                ManagedWorktreeSessionHolder {
7081                    incarnation_id: self.incarnation_id,
7082                    instance_id: session.session.instance_id,
7083                    generation: session.session.generation,
7084                },
7085                record_id,
7086                unix_time_ms(),
7087            )
7088            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
7089        let snapshot = match bound {
7090            Ok(snapshot) => snapshot,
7091            Err(error) => {
7092                if raw_inventory_record_id
7093                    .as_ref()
7094                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
7095                {
7096                    self.mark_managed_recovery_required(
7097                        &lease.lease_id,
7098                        ManagedWorktreeCleanupFailure::Backend,
7099                    );
7100                }
7101                let _ = self.remove_session(&session).await;
7102                self.mark_managed_recovery_required(
7103                    &lease.lease_id,
7104                    ManagedWorktreeCleanupFailure::Backend,
7105                );
7106                let result = Err(error);
7107                self.remember_managed_spawn_attempt(
7108                    durable_v2_key,
7109                    request,
7110                    result.clone(),
7111                    accepted_at,
7112                );
7113                return result;
7114            }
7115        };
7116        if let Err(error) = self.persist_state() {
7117            if raw_inventory_record_id
7118                .as_ref()
7119                .is_some_and(|record_id| self.discard_record(record_id).is_err())
7120            {
7121                self.mark_managed_recovery_required(
7122                    &lease.lease_id,
7123                    ManagedWorktreeCleanupFailure::Backend,
7124                );
7125            }
7126            let _ = self.remove_session(&session).await;
7127            if self
7128                .cleanup_managed_worktree(&lease.lease_id, true)
7129                .await
7130                .is_err()
7131            {
7132                self.mark_managed_recovery_required(
7133                    &lease.lease_id,
7134                    ManagedWorktreeCleanupFailure::Backend,
7135                );
7136            }
7137            let result = Err(persistence_failure(error));
7138            self.remember_managed_spawn_attempt(
7139                durable_v2_key,
7140                request,
7141                result.clone(),
7142                accepted_at,
7143            );
7144            return result;
7145        }
7146        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7147        let receipt = managed_spawn_receipt(
7148            &resolved,
7149            self.incarnation_id,
7150            session,
7151            snapshot,
7152            environment_profile,
7153            bundle,
7154            context,
7155        );
7156        let result = Ok(receipt);
7157        self.remember_managed_spawn_attempt(
7158            durable_v2_key,
7159            request,
7160            result.clone(),
7161            accepted_at,
7162        );
7163        result
7164    }
7165
7166    async fn spawn_managed_worktree_v2(
7167        &self,
7168        request: ManagedWorktreeSpawnRequestV2,
7169    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
7170        if request.spawn_spec.target.node_id != self.node_id {
7171            return Err(failure(
7172                NodeFailureCode::SpawnTargetMismatch,
7173                "spawn target does not match this node",
7174            ));
7175        }
7176        if request.spawn_spec.target.worktree_id.is_some() {
7177            return Err(failure(
7178                NodeFailureCode::InvalidRequest,
7179                "managed worktree spawn must not select a caller-provided worktree",
7180            ));
7181        }
7182        let request_digest = managed_spawn_request_digest_v2(&request)?;
7183        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
7184        match replay {
7185            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
7186            DurableManagedSpawnReplayDecision::PendingLinked => {
7187                return Err(failure(
7188                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
7189                    "managed worktree V2 reservation has an incomplete durable allocation",
7190                ));
7191            }
7192            DurableManagedSpawnReplayDecision::Reserved => {}
7193        }
7194        let validated = (|| {
7195            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
7196            let profile = self.managed_profile(
7197                &resolved.target.workspace_id,
7198                &request.worktree_profile_id,
7199            )?;
7200            if profile.revision() != &request.expected_profile_revision {
7201                return Err(failure(
7202                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
7203                    "managed worktree profile revision does not match the loaded profile",
7204                ));
7205            }
7206            Ok(())
7207        })();
7208        match validated {
7209            Ok(()) => {}
7210            Err(error) => {
7211                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7212                return Err(error);
7213            }
7214        }
7215        let legacy = ManagedWorktreeSpawnRequest {
7216            spawn_spec: request.spawn_spec.clone(),
7217            worktree_profile_id: request.worktree_profile_id.clone(),
7218        };
7219        match self
7220            .spawn_managed_worktree_inner(
7221                legacy,
7222                Some(&request.spawn_spec.idempotency_key),
7223            )
7224            .await
7225        {
7226            Ok(receipt) => {
7227                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
7228                Ok(receipt)
7229            }
7230            Err(error) => {
7231                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7232                Err(error)
7233            }
7234        }
7235    }
7236
7237    fn managed_profile(
7238        &self,
7239        workspace_id: &WorkspaceId,
7240        profile_id: &WorktreeProfileId,
7241    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
7242        if self.worktree_service_modes.get(workspace_id).copied()
7243            != Some(WorktreeServiceMode::Managed)
7244        {
7245            return Err(failure(
7246                NodeFailureCode::UnsupportedSpawnCapability,
7247                "workspace does not enable managed worktrees",
7248            ));
7249        }
7250        self.managed_worktree_profiles.get(workspace_id)
7251            .and_then(|profiles| profiles.get(profile_id))
7252            .cloned()
7253            .ok_or_else(|| failure(
7254                NodeFailureCode::InvalidRequest,
7255                "managed worktree profile is unavailable for this workspace",
7256            ))
7257    }
7258
7259    async fn reconcile_failed_allocation(
7260        &self,
7261        lease: &ManagedWorktreeLeaseRecord,
7262        source_root: &str,
7263    ) {
7264        match list_git_worktrees(source_root).await {
7265            Ok(worktrees) => {
7266                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
7267                    None => {
7268                        match std::fs::symlink_metadata(&lease.target_root) {
7269                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
7270                                self.managed_worktrees.lock()
7271                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7272                                    .remove_unmutated(&lease.lease_id);
7273                                let _ = self.persist_state();
7274                            }
7275                            _ => self.mark_managed_recovery_required(
7276                                &lease.lease_id,
7277                                ManagedWorktreeCleanupFailure::OwnershipConflict,
7278                            ),
7279                        }
7280                    }
7281                    Some(item) if exact_owned_worktree(
7282                        lease,
7283                        &item.path,
7284                        item.branch.as_deref(),
7285                        &item.head,
7286                    ) => {
7287                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7288                    }
7289                    Some(_) => self.mark_managed_recovery_required(
7290                        &lease.lease_id,
7291                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7292                    ),
7293                }
7294            }
7295            Err(_) => self.mark_managed_recovery_required(
7296                &lease.lease_id,
7297                ManagedWorktreeCleanupFailure::Backend,
7298            ),
7299        }
7300    }
7301
7302    async fn reconcile_managed_worktrees(&self) {
7303        let leases = self.managed_worktrees.lock()
7304            .unwrap_or_else(|poisoned| poisoned.into_inner())
7305            .active_records()
7306            .cloned()
7307            .collect::<Vec<_>>();
7308        for lease in leases {
7309            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7310                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7311                _ => {
7312                    self.mark_managed_recovery_required(
7313                        &lease.lease_id,
7314                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7315                    );
7316                    continue;
7317                }
7318            };
7319            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7320                self.mark_managed_recovery_required(
7321                    &lease.lease_id,
7322                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7323                );
7324                continue;
7325            }
7326            let source_root = match self.workspace_root(&lease.source_workspace_id) {
7327                Ok(root) => root,
7328                Err(_) => {
7329                    self.mark_managed_recovery_required(
7330                        &lease.lease_id,
7331                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7332                    );
7333                    continue;
7334                }
7335            };
7336            let worktrees = match list_git_worktrees(&source_root).await {
7337                Ok(worktrees) => worktrees,
7338                Err(_) => {
7339                    self.mark_managed_recovery_required(
7340                        &lease.lease_id,
7341                        ManagedWorktreeCleanupFailure::Backend,
7342                    );
7343                    continue;
7344                }
7345            };
7346            let listed = worktrees.iter()
7347                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7348            let Some(listed) = listed else {
7349                match std::fs::symlink_metadata(&lease.target_root) {
7350                    Err(error) if error.kind() == io::ErrorKind::NotFound
7351                        && lease.state == ManagedWorktreeLeaseState::Allocating
7352                        && !lease.has_holders() => {
7353                            let removed = self.managed_worktrees.lock()
7354                                .unwrap_or_else(|poisoned| poisoned.into_inner())
7355                                .tombstone(&lease.lease_id, unix_time_ms());
7356                            if self.persist_state().is_ok() && removed.is_some() {
7357                                self.publish(NodeEvent::ManagedWorktreeRemoved {
7358                                    lease_id: lease.lease_id.clone(),
7359                                });
7360                            }
7361                        }
7362                    _ => self.mark_managed_recovery_required(
7363                        &lease.lease_id,
7364                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7365                    ),
7366                }
7367                continue;
7368            };
7369            if listed.is_main || listed.is_bare || !exact_owned_worktree(
7370                &lease,
7371                &listed.path,
7372                listed.branch.as_deref(),
7373                &listed.head,
7374            ) {
7375                self.mark_managed_recovery_required(
7376                    &lease.lease_id,
7377                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7378                );
7379                continue;
7380            }
7381            if !self.workspaces.read()
7382                .unwrap_or_else(|poisoned| poisoned.into_inner())
7383                .contains_key(&lease.workspace_id)
7384            {
7385                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7386                    self.mark_managed_recovery_required(
7387                        &lease.lease_id,
7388                        ManagedWorktreeCleanupFailure::Backend,
7389                    );
7390                    continue;
7391                }
7392            }
7393            let snapshot = {
7394                let mut registry = self.managed_worktrees.lock()
7395                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7396                let current = registry.get_mut(&lease.lease_id)
7397                    .expect("reconciled managed lease remains present");
7398                current.expected_head = Some(listed.head.clone());
7399                current.cleanup_failure = None;
7400                current.state = if current.has_holders() {
7401                    ManagedWorktreeLeaseState::InUse
7402                } else if current.retention == ManagedWorktreeRetention::Retain {
7403                    ManagedWorktreeLeaseState::Retained
7404                } else {
7405                    ManagedWorktreeLeaseState::Ready
7406                };
7407                current.updated_at_unix_ms = unix_time_ms();
7408                current.snapshot()
7409            };
7410            if self.persist_state().is_err() {
7411                self.mark_managed_recovery_required(
7412                    &lease.lease_id,
7413                    ManagedWorktreeCleanupFailure::Backend,
7414                );
7415                continue;
7416            }
7417            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7418            if snapshot.state == ManagedWorktreeLeaseState::Ready {
7419                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7420            }
7421        }
7422    }
7423
7424    /// Crash-recovery startup sweep: durably capture the pack for every
7425    /// `Live` managed record whose most recent session has not yet produced
7426    /// one (recovers a session whose `Exited{0}` event was observed but the
7427    /// Node crashed before the export finished). The reactive fast path for
7428    /// a single just-exited record does not go through this — see
7429    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
7430    /// it can capture the exact `SessionAddress` before `publish_control`
7431    /// clears it, rather than re-deriving it from (by then likely already
7432    /// `Dormant`) record state the way a rescan naturally would.
7433    ///
7434    /// Returns as soon as every eligible record's export has been *spawned*,
7435    /// not completed — each one runs on its own detached task (see
7436    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
7437    /// `NativeRuntime::tick()` has not even started its own loop yet at the
7438    /// point this is called from `NodeServer::run()`, so nothing could ever
7439    /// drain the history round trip if this awaited it inline here either).
7440    /// Best-effort throughout: never panics, no unwrap/expect; every failure
7441    /// is a silent skip for THIS record — an unusable source or a racing
7442    /// record is not surfaced to any caller.
7443    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7444        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7445            .session_records
7446            .lock()
7447            .unwrap_or_else(|poisoned| poisoned.into_inner())
7448            .records
7449            .values()
7450            .filter(|record| {
7451                record.state == ManagedSessionState::Live
7452                    && record.exported_context.is_none()
7453                    && record.provider_session.is_some()
7454            })
7455            .filter_map(|record| {
7456                record
7457                    .active_session
7458                    .clone()
7459                    .map(|session| (record.record_id.clone(), session))
7460            })
7461            .collect();
7462        for (record_id, session) in candidates {
7463            let shared = Arc::clone(self);
7464            tokio::spawn(async move {
7465                shared
7466                    .reconcile_context_pack_export_for_record(&record_id, session)
7467                    .await;
7468            });
7469        }
7470    }
7471
7472    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
7473    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
7474    /// round trip this performs (`discover_history`/`load_history`, via
7475    /// `export_context_pack_for_session_record`) can only ever settle once
7476    /// `NativeRuntime::tick()` runs again to drain the authority worker's
7477    /// completion — and `tick()` is exclusively owned by, and only ever
7478    /// called from, that same drive-loop task. Awaiting this inline there
7479    /// would starve `tick()` and this call could never observe its own
7480    /// command complete.
7481    ///
7482    /// Because this now genuinely races the drive loop's own
7483    /// `publish_control` (which downgrades this exact record from `Live` to
7484    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
7485    /// triggered this reconcile), the record legitimately may already be
7486    /// `Dormant` by the time this reads it, or may transition mid-flight —
7487    /// `allow_clean_detachment: true` on the downstream export call is what
7488    /// tolerates exactly that transition (and only that transition; see
7489    /// `session_record_export_target_matches`).
7490    async fn reconcile_context_pack_export_for_record(
7491        &self,
7492        record_id: &SessionRecordId,
7493        session: SessionAddress,
7494    ) {
7495        let Ok(record) = self.record(record_id) else {
7496            return;
7497        };
7498        if !matches!(
7499            record.state,
7500            ManagedSessionState::Live | ManagedSessionState::Dormant
7501        ) || record.exported_context.is_some()
7502            || record.provider_session.is_none()
7503        {
7504            return;
7505        }
7506        // Pack bytes are already durably committed inside this call, via
7507        // commit_context_pack_for_session_record's own durable
7508        // write-through (context_pack_store) — no separate step here.
7509        // Unclean exits (non-zero/forced/no loadable history) are already
7510        // rejected by context_pack_source_status_is_usable inside
7511        // materialize_context_pack: this simply returns Err and is skipped.
7512        let Ok(receipt) = self
7513            .export_context_pack_for_session_record(&record.record_id, &session, true)
7514            .await
7515        else {
7516            return;
7517        };
7518        self.set_exported_context(&record.record_id, receipt);
7519    }
7520
7521    /// Records the record's most recent clean-exit pack. No-op (not an
7522    /// error) if the record disappeared, or if it already carries an
7523    /// `exported_context` — first-write-wins, matching the harness-side
7524    /// idempotent reconciliation that consumes this field.
7525    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7526        let transaction = self
7527            .state_transaction
7528            .lock()
7529            .unwrap_or_else(|poisoned| poisoned.into_inner());
7530        let (previous, updated) = {
7531            let mut records = self
7532                .session_records
7533                .lock()
7534                .unwrap_or_else(|poisoned| poisoned.into_inner());
7535            let Some(record) = records.records.get_mut(record_id) else {
7536                return;
7537            };
7538            if record.exported_context.is_some() {
7539                return;
7540            }
7541            let previous = record.clone();
7542            record.exported_context = Some(receipt);
7543            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7544            (previous, record.clone())
7545        };
7546        if self.persist_state_locked().is_err() {
7547            self.session_records
7548                .lock()
7549                .unwrap_or_else(|poisoned| poisoned.into_inner())
7550                .records
7551                .insert(record_id.clone(), previous);
7552            return;
7553        }
7554        drop(transaction);
7555        self.publish_record(updated);
7556    }
7557
7558    /// Resolves the managed record and its exact `SessionAddress` currently
7559    /// bound to `instance_id` at exactly `generation`, used by the reactive
7560    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
7561    /// record without a full rescan. Mirrors the lookup already inlined in
7562    /// `publish_control`. Captured synchronously, before `publish_control`
7563    /// for the same event clears the record's own `active_session` — the
7564    /// caller passes the resolved address through to a detached
7565    /// reconciliation task rather than re-deriving it from the record later,
7566    /// after that race has necessarily already happened.
7567    fn managed_record_export_target_for_instance(
7568        &self,
7569        instance_id: AgentInstanceId,
7570        generation: SessionGeneration,
7571    ) -> Option<(SessionRecordId, SessionAddress)> {
7572        let bindings = self
7573            .session_bindings
7574            .lock()
7575            .unwrap_or_else(|poisoned| poisoned.into_inner());
7576        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7577        let record_id = binding.record_id.clone()?;
7578        Some((
7579            record_id,
7580            SessionAddress {
7581                workspace_id: binding.workspace_id.clone(),
7582                session: SessionKey { instance_id, generation },
7583            },
7584        ))
7585    }
7586
7587    fn mark_managed_recovery_required(
7588        &self,
7589        lease_id: &ManagedWorktreeLeaseId,
7590        failure_kind: ManagedWorktreeCleanupFailure,
7591    ) {
7592        let snapshot = {
7593            let mut registry = self.managed_worktrees.lock()
7594                .unwrap_or_else(|poisoned| poisoned.into_inner());
7595            let Some(lease) = registry.get_mut(lease_id) else { return };
7596            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7597            lease.cleanup_failure = Some(failure_kind);
7598            lease.updated_at_unix_ms = unix_time_ms();
7599            lease.snapshot()
7600        };
7601        let _ = self.persist_state();
7602        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7603    }
7604
7605    fn set_persistence_error(&self, error: Option<String>) {
7606        *self
7607            .persistence_error
7608            .write()
7609            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7610            .map(|message| session_registry::sanitized_persistence_summary(&message));
7611    }
7612
7613    fn persist_state(&self) -> io::Result<()> {
7614        let _transaction = self
7615            .state_transaction
7616            .lock()
7617            .unwrap_or_else(|poisoned| poisoned.into_inner());
7618        self.persist_state_locked()
7619    }
7620
7621    fn persist_state_locked(&self) -> io::Result<()> {
7622        let workspaces = self
7623            .workspaces
7624            .read()
7625            .unwrap_or_else(|poisoned| poisoned.into_inner())
7626            .clone();
7627        let records = self
7628            .session_records
7629            .lock()
7630            .unwrap_or_else(|poisoned| poisoned.into_inner())
7631            .records
7632            .values()
7633            .cloned()
7634            .collect::<Vec<_>>();
7635        let mut managed = self.managed_worktrees
7636            .lock()
7637            .unwrap_or_else(|poisoned| poisoned.into_inner());
7638        managed.reattach_record_holders(
7639            &managed_worktree_record_holders(&records),
7640            unix_time_ms(),
7641        );
7642        let materializations = self
7643            .materializations
7644            .lock()
7645            .unwrap_or_else(|poisoned| poisoned.into_inner())
7646            .values()
7647            .cloned()
7648            .collect::<Vec<_>>();
7649        let managed_spawn_replays = self.managed_spawn_replays
7650            .lock()
7651            .unwrap_or_else(|poisoned| poisoned.into_inner())
7652            .values()
7653            .cloned()
7654            .collect::<Vec<_>>();
7655        let result = session_registry::save_v10(
7656            self.state_path.as_deref(),
7657            &self.node_id,
7658            &workspaces,
7659            &records,
7660            &managed.records(),
7661            &managed.tombstones(),
7662            &materializations,
7663            &managed_spawn_replays,
7664        );
7665        match &result {
7666            Ok(warning) => self.set_persistence_error(warning.clone()),
7667            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7668        }
7669        result.map(|_| ())
7670    }
7671
7672    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7673        let _mutation_guard = self.mutation_gate.lock().await;
7674        self.begin_shutdown_locked().await
7675    }
7676
7677    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7678        if self.shutdown.swap(true, Ordering::AcqRel) {
7679            return Ok(());
7680        }
7681        SessionLifecycleHook::on_shutdown(self);
7682        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7683        let mut first_error = None;
7684        for session in &self.handle.snapshot().sessions {
7685            let remaining = deadline.saturating_duration_since(Instant::now());
7686            if remaining.is_zero() {
7687                first_error.get_or_insert_with(|| {
7688                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7689                });
7690                break;
7691            }
7692            if let Err(error) = self
7693                .dispatch_bounded(
7694                    ControlCommand::Stop {
7695                        instance_id: session.instance_id,
7696                        force: true,
7697                    },
7698                    remaining,
7699                )
7700                .await
7701            {
7702                first_error.get_or_insert(error);
7703            }
7704        }
7705        self.shutdown_notify.notify_waiters();
7706        match first_error {
7707            Some(error) => Err(error),
7708            None => Ok(()),
7709        }
7710    }
7711
7712    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7713        let bindings = self
7714            .session_bindings
7715            .lock()
7716            .unwrap_or_else(|poisoned| poisoned.into_inner());
7717        if bindings.len() >= CONTROL_SESSIONS_MAX {
7718            Err(failure(
7719                NodeFailureCode::BackendBusy,
7720                "node session-binding capacity is full; remove an existing session first",
7721            ))
7722        } else {
7723            Ok(())
7724        }
7725    }
7726
7727    /// One-owner workspace/env collision guard: a live session on
7728    /// `workspace_id` with a different `environment_profile` (option
7729    /// equality on the resolved receipt, including Some vs None) blocks
7730    /// another spawn on that same cwd. Same profile may still share the
7731    /// workspace; distinct profiles need distinct workspace ids (managed
7732    /// worktree).
7733    fn ensure_workspace_environment_profile_compatible(
7734        &self,
7735        workspace_id: &WorkspaceId,
7736        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7737    ) -> Result<(), NodeFailure> {
7738        let bindings = self
7739            .session_bindings
7740            .lock()
7741            .unwrap_or_else(|poisoned| poisoned.into_inner());
7742        for binding in bindings.values() {
7743            if &binding.workspace_id != workspace_id {
7744                continue;
7745            }
7746            if binding.environment_profile.as_ref() != environment_profile {
7747                return Err(failure(
7748                    NodeFailureCode::WorkspaceBusy,
7749                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7750                ));
7751            }
7752        }
7753        Ok(())
7754    }
7755
7756    #[cfg(test)]
7757    fn bind_session(
7758        &self,
7759        instance_id: AgentInstanceId,
7760        workspace_id: WorkspaceId,
7761        generation: SessionGeneration,
7762    ) {
7763        self.bind_session_with_policy(
7764            &SessionAddress {
7765                workspace_id,
7766                session: SessionKey { instance_id, generation },
7767            },
7768            ProviderRuntimePolicy::raw_pty(),
7769            None,
7770        );
7771    }
7772
7773    #[cfg(test)]
7774    fn bind_session_with_policy(
7775        &self,
7776        address: &SessionAddress,
7777        runtime_policy: ProviderRuntimePolicy,
7778        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7779    ) {
7780        self.bind_session_with_materialization(
7781            address,
7782            runtime_policy,
7783            environment_profile,
7784            None,
7785            None,
7786            None,
7787        );
7788    }
7789
7790    fn bind_session_with_materialization(
7791        &self,
7792        address: &SessionAddress,
7793        runtime_policy: ProviderRuntimePolicy,
7794        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7795        bundle: Option<ResolvedBundleReceipt>,
7796        context: Option<ResolvedContextPackReceipt>,
7797        materialization_id: Option<MaterializationId>,
7798    ) {
7799        let mut bindings = self
7800            .session_bindings
7801            .lock()
7802            .unwrap_or_else(|poisoned| poisoned.into_inner());
7803        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7804        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7805        bindings.insert(address.session.instance_id, SessionBinding {
7806            workspace_id: address.workspace_id.clone(),
7807            generation: address.session.generation,
7808            runtime_policy,
7809            pending_resume: None,
7810            record_id: None,
7811            managed_worktree_lease_id: None,
7812            environment_profile,
7813            bundle,
7814            context,
7815            materialization_id,
7816        });
7817    }
7818
7819    #[cfg(test)]
7820    fn bind_managed_session(
7821        &self,
7822        address: &SessionAddress,
7823        record_id: SessionRecordId,
7824        runtime_policy: ProviderRuntimePolicy,
7825        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7826    ) {
7827        self.bind_managed_session_with_materialization(
7828            address,
7829            record_id,
7830            runtime_policy,
7831            environment_profile,
7832            None,
7833            None,
7834            None,
7835        );
7836    }
7837
7838    fn bind_managed_session_with_materialization(
7839        &self,
7840        address: &SessionAddress,
7841        record_id: SessionRecordId,
7842        runtime_policy: ProviderRuntimePolicy,
7843        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7844        bundle: Option<ResolvedBundleReceipt>,
7845        context: Option<ResolvedContextPackReceipt>,
7846        materialization_id: Option<MaterializationId>,
7847    ) {
7848        let mut bindings = self
7849            .session_bindings
7850            .lock()
7851            .unwrap_or_else(|poisoned| poisoned.into_inner());
7852        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7853        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7854        bindings.insert(
7855            address.session.instance_id,
7856            SessionBinding {
7857                workspace_id: address.workspace_id.clone(),
7858                generation: address.session.generation,
7859                runtime_policy,
7860                pending_resume: None,
7861                record_id: Some(record_id),
7862                managed_worktree_lease_id: None,
7863                environment_profile,
7864                bundle,
7865                context,
7866                materialization_id,
7867            },
7868        );
7869    }
7870
7871    #[cfg(test)]
7872    fn bind_spawn_session(
7873        &self,
7874        address: &SessionAddress,
7875        provider: AgentId,
7876        mode: SessionMode,
7877        runtime_policy: ProviderRuntimePolicy,
7878        record_policy: SpawnRecordPolicy,
7879        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7880    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7881        self.bind_spawn_session_with_materialization(
7882            address,
7883            provider,
7884            mode,
7885            runtime_policy,
7886            record_policy,
7887            environment_profile,
7888            None,
7889            None,
7890            None,
7891        )
7892    }
7893
7894    fn bind_spawn_session_with_materialization(
7895        &self,
7896        address: &SessionAddress,
7897        provider: AgentId,
7898        mode: SessionMode,
7899        runtime_policy: ProviderRuntimePolicy,
7900        record_policy: SpawnRecordPolicy,
7901        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7902        bundle: Option<ResolvedBundleReceipt>,
7903        context: Option<ResolvedContextPackReceipt>,
7904        materialization_id: Option<MaterializationId>,
7905    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7906        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7907            && !runtime_policy.provider_session_identity
7908        {
7909            self.bind_session_with_materialization(
7910                address,
7911                runtime_policy,
7912                environment_profile,
7913                bundle,
7914                context,
7915                materialization_id,
7916            );
7917            return Ok(None);
7918        }
7919        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7920        let record = self.new_record(
7921            address,
7922            provider,
7923            mode,
7924            active_record_state(runtime_policy.provider_session_identity, false),
7925            None,
7926            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7927            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7928            record_owns_lifecycle.then(|| context.clone()).flatten(),
7929        )?;
7930        let record_id = record.record_id.clone();
7931        let transaction = self
7932            .state_transaction
7933            .lock()
7934            .unwrap_or_else(|poisoned| poisoned.into_inner());
7935        self.insert_record(record.clone())?;
7936        let previous_materialization = if record_owns_lifecycle {
7937            if let Some(materialization_id) = materialization_id.as_ref() {
7938                let mut materializations = self
7939                    .materializations
7940                    .lock()
7941                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7942                let Some(ownership) = materializations.get_mut(materialization_id) else {
7943                    drop(materializations);
7944                    self.remove_record_memory(&record_id);
7945                    return Err(failure(
7946                        NodeFailureCode::BackendOperationFailed,
7947                        "session environment ownership disappeared before binding",
7948                    ));
7949                };
7950                let previous = ownership.clone();
7951                if ownership
7952                    .transfer_to_record(record_id.clone(), unix_time_ms())
7953                    .is_err()
7954                {
7955                    drop(materializations);
7956                    self.remove_record_memory(&record_id);
7957                    return Err(failure(
7958                        NodeFailureCode::BackendOperationFailed,
7959                        "session environment ownership transfer failed",
7960                    ));
7961                }
7962                Some(previous)
7963            } else {
7964                None
7965            }
7966        } else {
7967            None
7968        };
7969        self.bind_managed_session_with_materialization(
7970            address,
7971            record_id.clone(),
7972            runtime_policy,
7973            environment_profile,
7974            bundle,
7975            context,
7976            materialization_id.clone(),
7977        );
7978        if let Err(error) = self.persist_state_locked() {
7979            self.remove_binding(address);
7980            self.remove_record_memory(&record_id);
7981            if let (Some(materialization_id), Some(previous)) =
7982                (materialization_id.as_ref(), previous_materialization)
7983            {
7984                self.materializations
7985                    .lock()
7986                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7987                    .insert(materialization_id.clone(), previous);
7988            }
7989            return Err(persistence_failure(error));
7990        }
7991        drop(transaction);
7992        self.publish_record(record);
7993        Ok(Some(record_id))
7994    }
7995
7996    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7997        for _ in 0..8 {
7998            let bytes = random_nonce().map_err(|error| {
7999                failure(
8000                    NodeFailureCode::BackendOperationFailed,
8001                    &format!("session record identity generation failed: {error}"),
8002                )
8003            })?;
8004            let mut value = String::with_capacity(27);
8005            value.push_str("sr-");
8006            for byte in &bytes[..12] {
8007                use std::fmt::Write as _;
8008                let _ = write!(&mut value, "{byte:02x}");
8009            }
8010            let record_id = SessionRecordId::new(value).map_err(|error| {
8011                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
8012            })?;
8013            let records = self
8014                .session_records
8015                .lock()
8016                .unwrap_or_else(|poisoned| poisoned.into_inner());
8017            if !records.records.contains_key(&record_id) {
8018                return Ok(record_id);
8019            }
8020        }
8021        Err(failure(
8022            NodeFailureCode::BackendBusy,
8023            "could not allocate a unique session record ID",
8024        ))
8025    }
8026
8027    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
8028        for _ in 0..8 {
8029            let bytes = random_nonce().map_err(|error| {
8030                failure(
8031                    NodeFailureCode::BackendOperationFailed,
8032                    &format!("task identity generation failed: {error}"),
8033                )
8034            })?;
8035            let mut nonce = [0_u8; 12];
8036            nonce.copy_from_slice(&bytes[..12]);
8037            let task_id = TaskId::from_nonce(nonce);
8038            let records = self
8039                .session_records
8040                .lock()
8041                .unwrap_or_else(|poisoned| poisoned.into_inner());
8042            if !records.records.values().any(|record| {
8043                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
8044                    == Some(&task_id)
8045            }) {
8046                return Ok(task_id);
8047            }
8048        }
8049        Err(failure(
8050            NodeFailureCode::BackendBusy,
8051            "could not allocate a unique task ID",
8052        ))
8053    }
8054
8055    fn default_record_name(&self, provider: &AgentId) -> String {
8056        let records = self
8057            .session_records
8058            .lock()
8059            .unwrap_or_else(|poisoned| poisoned.into_inner());
8060        let ordinal = records
8061            .records
8062            .values()
8063            .filter(|record| &record.provider == provider)
8064            .count()
8065            .saturating_add(1);
8066        format!("{} #{ordinal}", provider.as_str())
8067    }
8068
8069    fn new_record(
8070        &self,
8071        address: &SessionAddress,
8072        provider: AgentId,
8073        mode: SessionMode,
8074        state: ManagedSessionState,
8075        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
8076        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
8077        bundle: Option<ResolvedBundleReceipt>,
8078        context: Option<ResolvedContextPackReceipt>,
8079    ) -> Result<ManagedSessionRecord, NodeFailure> {
8080        let canonical_root = self.workspace_root(&address.workspace_id)?;
8081        let now = unix_time_ms();
8082        Ok(ManagedSessionRecord {
8083            record_id: self.allocate_record_id()?,
8084            display_name: self.m4a_nick_of(address.session.instance_id).unwrap_or_else(|| self.default_record_name(&provider)),
8085            provider,
8086            mode,
8087            state,
8088            workspace_id: address.workspace_id.clone(),
8089            canonical_root: opaque_windows_path(canonical_root),
8090            provider_session,
8091            active_session: Some(address.clone()),
8092            environment_profile,
8093            bundle,
8094            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
8095            context,
8096            exported_context: None,
8097            task_binding: None,
8098            created_at_unix_ms: now,
8099            updated_at_unix_ms: now,
8100            last_error: None,
8101        })
8102    }
8103
8104    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
8105        let mut records = self
8106            .session_records
8107            .lock()
8108            .unwrap_or_else(|poisoned| poisoned.into_inner());
8109        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8110            return Err(failure(
8111                NodeFailureCode::BackendBusy,
8112                "managed session record capacity is full; forget an inactive record first",
8113            ));
8114        }
8115        if let Some(identity) = record.provider_session.as_ref() {
8116            if records.records.values().any(|candidate| {
8117                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8118                    session_registry::same_provider_session(
8119                        &candidate.provider,
8120                        candidate_identity,
8121                        &record.provider,
8122                        identity,
8123                    )
8124                })
8125            }) {
8126                return Err(failure(
8127                    NodeFailureCode::SessionRecordConflict,
8128                    "provider session is already represented by another managed record",
8129                ));
8130            }
8131        }
8132        match records.records.entry(record.record_id.clone()) {
8133            std::collections::btree_map::Entry::Vacant(entry) => {
8134                entry.insert(record);
8135            }
8136            std::collections::btree_map::Entry::Occupied(_) => {
8137                return Err(failure(
8138                    NodeFailureCode::SessionRecordConflict,
8139                    "session record ID collision",
8140                ));
8141            }
8142        }
8143        Ok(())
8144    }
8145
8146    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
8147        self.session_records
8148            .lock()
8149            .unwrap_or_else(|poisoned| poisoned.into_inner())
8150            .records
8151            .get(record_id)
8152            .cloned()
8153            .ok_or_else(|| {
8154                failure(
8155                    NodeFailureCode::UnknownSessionRecord,
8156                    "managed session record does not exist",
8157                )
8158            })
8159    }
8160
8161    fn revalidate_session_record_preview(
8162        &self,
8163        expected: &ManagedSessionRecord,
8164        identity: &ProviderSessionIdentity,
8165    ) -> Result<(), NodeFailure> {
8166        let current = self.record(&expected.record_id)?;
8167        let current_root = self.workspace_root(&current.workspace_id)?;
8168        if current.provider != expected.provider
8169            || current.workspace_id != expected.workspace_id
8170            || current.canonical_root != expected.canonical_root
8171            || current.provider_session.as_ref() != Some(identity)
8172            || !platform::roots_equal(
8173                &current_root,
8174                windows_path_text(&current.canonical_root),
8175            )
8176        {
8177            return Err(failure(
8178                NodeFailureCode::SessionRecordConflict,
8179                "managed session changed while its preview was loading",
8180            ));
8181        }
8182        Ok(())
8183    }
8184
8185    fn revalidate_session_record_context_export(
8186        &self,
8187        expected: &ManagedSessionRecord,
8188        identity: Option<&ProviderSessionIdentity>,
8189        session: &SessionAddress,
8190        allow_clean_detachment: bool,
8191    ) -> Result<(), NodeFailure> {
8192        let current = self.record(&expected.record_id)?;
8193        let current_root = self.workspace_root(&current.workspace_id)?;
8194        let snapshot = self.internal_session_snapshot(session)?;
8195        let binding = self
8196            .session_bindings
8197            .lock()
8198            .unwrap_or_else(|poisoned| poisoned.into_inner())
8199            .get(&session.session.instance_id)
8200            .cloned()
8201            .ok_or_else(|| {
8202                failure(
8203                    NodeFailureCode::SessionRecordConflict,
8204                    "managed session runtime binding disappeared during context export",
8205                )
8206            })?;
8207        if !session_record_context_export_binding_is_exact(
8208            expected,
8209            &current,
8210            identity,
8211            session,
8212            &binding,
8213            &snapshot.agent_id,
8214            &current_root,
8215            allow_clean_detachment,
8216        ) || !session_record_context_export_source_is_usable(
8217            &current,
8218            &snapshot.status,
8219            allow_clean_detachment,
8220        ) {
8221            return Err(failure(
8222                NodeFailureCode::SessionRecordConflict,
8223                "managed session binding changed during context export",
8224            ));
8225        }
8226        Ok(())
8227    }
8228
8229    fn revalidate_loaded_history(
8230        &self,
8231        session: &SessionAddress,
8232        candidate_id: &str,
8233        loaded: &HistorySessionRecord,
8234    ) -> Result<(), NodeFailure> {
8235        let snapshot = self.internal_session_snapshot(session)?;
8236        if snapshot.history.pending.is_some()
8237            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
8238            || snapshot.history.loaded.as_ref() != Some(loaded)
8239        {
8240            return Err(failure(
8241                NodeFailureCode::SessionRecordConflict,
8242                "managed session history changed during context export",
8243            ));
8244        }
8245        Ok(())
8246    }
8247
8248    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
8249        self.session_records
8250            .lock()
8251            .unwrap_or_else(|poisoned| poisoned.into_inner())
8252            .records
8253            .remove(record_id)
8254    }
8255
8256    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
8257        if self.destroy_record_materialization(record_id)? {
8258            self.publish(NodeEvent::SessionRecordRemoved {
8259                record_id: record_id.clone(),
8260            });
8261            return Ok(());
8262        }
8263        let transaction = self
8264            .state_transaction
8265            .lock()
8266            .unwrap_or_else(|poisoned| poisoned.into_inner());
8267        let Some(removed) = self.remove_record_memory(record_id) else {
8268            return Ok(());
8269        };
8270        if let Err(error) = self.persist_state_locked() {
8271            let _ = self.insert_record(removed);
8272            return Err(persistence_failure(error));
8273        }
8274        drop(transaction);
8275        self.publish(NodeEvent::SessionRecordRemoved {
8276            record_id: record_id.clone(),
8277        });
8278        Ok(())
8279    }
8280
8281    fn publish_record(&self, record: ManagedSessionRecord) {
8282        self.publish(NodeEvent::SessionRecordUpserted { record });
8283    }
8284
8285    fn mark_record_error(
8286        &self,
8287        record_id: &SessionRecordId,
8288        message: &str,
8289    ) -> Result<(), NodeFailure> {
8290        let transaction = self
8291            .state_transaction
8292            .lock()
8293            .unwrap_or_else(|poisoned| poisoned.into_inner());
8294        let previous = {
8295            let mut records = self
8296                .session_records
8297                .lock()
8298                .unwrap_or_else(|poisoned| poisoned.into_inner());
8299            let Some(record) = records.records.get_mut(record_id) else {
8300                return Ok(());
8301            };
8302            let previous = record.clone();
8303            record.state = ManagedSessionState::Unavailable;
8304            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8305            record.updated_at_unix_ms = unix_time_ms();
8306            previous
8307        };
8308        if let Err(error) = self.persist_state_locked() {
8309            self.session_records
8310                .lock()
8311                .unwrap_or_else(|poisoned| poisoned.into_inner())
8312                .records
8313                .insert(record_id.clone(), previous);
8314            return Err(persistence_failure(error));
8315        }
8316        let record = self.record(record_id)?;
8317        drop(transaction);
8318        self.publish_record(record);
8319        Ok(())
8320    }
8321
8322    fn rename_session_record(
8323        &self,
8324        record_id: &SessionRecordId,
8325        display_name: String,
8326    ) -> Result<ManagedSessionRecord, NodeFailure> {
8327        validate_display_name(&display_name)
8328            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8329        let _transaction = self
8330            .state_transaction
8331            .lock()
8332            .unwrap_or_else(|poisoned| poisoned.into_inner());
8333        let previous = {
8334            let mut records = self
8335                .session_records
8336                .lock()
8337                .unwrap_or_else(|poisoned| poisoned.into_inner());
8338            let record = records.records.get_mut(record_id).ok_or_else(|| {
8339                failure(
8340                    NodeFailureCode::UnknownSessionRecord,
8341                    "managed session record does not exist",
8342                )
8343            })?;
8344            let previous = record.clone();
8345            record.display_name = display_name;
8346            record.updated_at_unix_ms = unix_time_ms();
8347            previous
8348        };
8349        if let Err(error) = self.persist_state_locked() {
8350            self.session_records
8351                .lock()
8352                .unwrap_or_else(|poisoned| poisoned.into_inner())
8353                .records
8354                .insert(record_id.clone(), previous);
8355            return Err(persistence_failure(error));
8356        }
8357        let updated = self.record(record_id)?;
8358        self.publish_record(updated.clone());
8359        Ok(updated)
8360    }
8361
8362    fn set_session_task(
8363        &self,
8364        record_id: &SessionRecordId,
8365        expected_revision: u64,
8366        target: SessionTaskTargetV1,
8367    ) -> Result<ManagedSessionRecord, NodeFailure> {
8368        let transaction = self
8369            .state_transaction
8370            .lock()
8371            .unwrap_or_else(|poisoned| poisoned.into_inner());
8372        let current = self.record(record_id)?;
8373        let current_task_id = current
8374            .task_binding
8375            .as_ref()
8376            .and_then(|binding| binding.task_id.as_ref());
8377        let current_revision = current
8378            .task_binding
8379            .as_ref()
8380            .map_or(0, |binding| binding.revision);
8381        if expected_revision != current_revision {
8382            return Err(failure(
8383                NodeFailureCode::SessionRecordConflict,
8384                "managed session task binding revision changed",
8385            ));
8386        }
8387        let exact = match &target {
8388            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8389            SessionTaskTargetV1::Clear => current_task_id.is_none(),
8390            SessionTaskTargetV1::New => false,
8391        };
8392        if exact {
8393            return Ok(current);
8394        }
8395        let revision = current_revision.checked_add(1).ok_or_else(|| {
8396            failure(
8397                NodeFailureCode::SessionRecordConflict,
8398                "managed session task binding revision is exhausted",
8399            )
8400        })?;
8401        let task_id = match target {
8402            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8403            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8404            SessionTaskTargetV1::Clear => None,
8405        };
8406        let changed_at_unix_ms = unix_time_ms()
8407            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8408                failure(
8409                    NodeFailureCode::SessionRecordConflict,
8410                    "managed session record timestamp is exhausted",
8411                )
8412            })?);
8413        let updated = {
8414            let mut records = self
8415                .session_records
8416                .lock()
8417                .unwrap_or_else(|poisoned| poisoned.into_inner());
8418            let record = records.records.get_mut(record_id).ok_or_else(|| {
8419                failure(
8420                    NodeFailureCode::UnknownSessionRecord,
8421                    "managed session record does not exist",
8422                )
8423            })?;
8424            record.task_binding = Some(SessionTaskBindingV1 {
8425                revision,
8426                task_id,
8427                changed_at_unix_ms,
8428            });
8429            record.updated_at_unix_ms = changed_at_unix_ms;
8430            record.clone()
8431        };
8432        if let Err(error) = self.persist_state_locked() {
8433            self.session_records
8434                .lock()
8435                .unwrap_or_else(|poisoned| poisoned.into_inner())
8436                .records
8437                .insert(record_id.clone(), current);
8438            return Err(persistence_failure(error));
8439        }
8440        drop(transaction);
8441        self.publish_record(updated.clone());
8442        Ok(updated)
8443    }
8444
8445    fn index_provider_session(
8446        &self,
8447        workspace_id: WorkspaceId,
8448        provider: AgentId,
8449        identity: ProviderSessionIdentity,
8450        display_name: String,
8451    ) -> Result<ManagedSessionRecord, NodeFailure> {
8452        self.index_provider_session_with_policy(
8453            workspace_id,
8454            provider,
8455            identity,
8456            display_name,
8457            false,
8458        )
8459    }
8460
8461    fn index_provider_session_with_policy(
8462        &self,
8463        workspace_id: WorkspaceId,
8464        provider: AgentId,
8465        identity: ProviderSessionIdentity,
8466        display_name: String,
8467        allow_validated_transcript_path: bool,
8468    ) -> Result<ManagedSessionRecord, NodeFailure> {
8469        validate_display_name(&display_name)
8470            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8471        identity
8472            .validate()
8473            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8474        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8475            return Err(failure(
8476                NodeFailureCode::InvalidRequest,
8477                "provider session reference index v1 does not accept transcript paths",
8478            ));
8479        }
8480        let canonical_root = self.workspace_root(&workspace_id)?;
8481        if !self.enabled_providers.contains(&provider) {
8482            return Err(failure(
8483                NodeFailureCode::InvalidRequest,
8484                "provider is not enabled on this node",
8485            ));
8486        }
8487
8488        let transaction = self
8489            .state_transaction
8490            .lock()
8491            .unwrap_or_else(|poisoned| poisoned.into_inner());
8492        {
8493            let records = self
8494                .session_records
8495                .lock()
8496                .unwrap_or_else(|poisoned| poisoned.into_inner());
8497            if let Some(existing) = records.records.values().find(|candidate| {
8498                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8499                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
8500                })
8501            }) {
8502                if existing.provider == provider && existing.workspace_id == workspace_id {
8503                    return Ok(existing.clone());
8504                }
8505                return Err(failure(
8506                    NodeFailureCode::SessionRecordConflict,
8507                    "provider session reference conflicts with another provider or workspace",
8508                ));
8509            }
8510            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8511                return Err(failure(
8512                    NodeFailureCode::BackendBusy,
8513                    "managed session record capacity is full; forget an inactive record first",
8514                ));
8515            }
8516        }
8517
8518        let now = unix_time_ms();
8519        let record = ManagedSessionRecord {
8520            record_id: self.allocate_record_id()?,
8521            display_name,
8522            provider,
8523            mode: SessionMode::Pty,
8524            state: ManagedSessionState::Dormant,
8525            workspace_id,
8526            canonical_root: opaque_windows_path(canonical_root),
8527            provider_session: Some(identity),
8528            active_session: None,
8529            environment_profile: None,
8530            bundle: None,
8531            context_id: None,
8532            context: None,
8533            exported_context: None,
8534            task_binding: None,
8535            created_at_unix_ms: now,
8536            updated_at_unix_ms: now,
8537            last_error: None,
8538        };
8539        self.insert_record(record.clone())?;
8540        if let Err(error) = self.persist_state_locked() {
8541            self.remove_record_memory(&record.record_id);
8542            return Err(persistence_failure(error));
8543        }
8544        drop(transaction);
8545        self.publish_record(record.clone());
8546        Ok(record)
8547    }
8548
8549    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8550        self.session_bindings
8551            .lock()
8552            .unwrap_or_else(|poisoned| poisoned.into_inner())
8553            .iter()
8554            .find_map(|(instance_id, binding)| {
8555                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8556                    workspace_id: binding.workspace_id.clone(),
8557                    session: SessionKey {
8558                        instance_id: *instance_id,
8559                        generation: binding.generation,
8560                    },
8561                })
8562            })
8563    }
8564
8565    async fn forget_session_record(
8566        &self,
8567        record_id: &SessionRecordId,
8568    ) -> Result<(), NodeFailure> {
8569        let record = self.record(record_id)?;
8570        if record.active_session.is_some()
8571            || matches!(
8572                record.state,
8573                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8574            )
8575        {
8576            return Err(failure(
8577                NodeFailureCode::SessionRecordBusy,
8578                "stop the managed session before forgetting it",
8579            ));
8580        }
8581        if let Some(address) = self.bound_address_for_record(record_id) {
8582            self.remove_session(&address).await?;
8583        }
8584        self.remove_managed_session_record(record_id, &record.workspace_id).await
8585    }
8586
8587    /// Removes one managed session record from durable state and notifies
8588    /// every listener -- the shared transaction body behind both
8589    /// `forget_session_record`'s operator-driven path (busy-checked above
8590    /// it, and clears any still-live session binding before calling in
8591    /// here) and the retention sweep (`retire_unavailable_session_records`,
8592    /// which only ever selects `ManagedSessionState::Unavailable` records
8593    /// that are already known never-busy and never bound, so it calls
8594    /// straight into this with neither check). Cleans a managed-worktree
8595    /// lease if the record held one, removes the record's environment
8596    /// materialization (or, absent one, removes it from memory directly),
8597    /// persists under `state_transaction` with rollback on persist failure,
8598    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8599    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8600    /// exactly as it does for an operator-driven forget.
8601    async fn remove_managed_session_record(
8602        &self,
8603        record_id: &SessionRecordId,
8604        workspace_id: &WorkspaceId,
8605    ) -> Result<(), NodeFailure> {
8606        let managed_lease_id = self.managed_worktrees.lock()
8607            .unwrap_or_else(|poisoned| poisoned.into_inner())
8608            .lease_for_workspace(workspace_id);
8609        if !self.destroy_record_materialization(record_id)? {
8610            let transaction = self
8611                .state_transaction
8612                .lock()
8613                .unwrap_or_else(|poisoned| poisoned.into_inner());
8614            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8615                failure(
8616                    NodeFailureCode::UnknownSessionRecord,
8617                    "managed session record does not exist",
8618                )
8619            })?;
8620            if let Err(error) = self.persist_state_locked() {
8621                let _ = self.insert_record(removed);
8622                return Err(persistence_failure(error));
8623            }
8624            drop(transaction);
8625        }
8626        self.publish(NodeEvent::SessionRecordRemoved {
8627            record_id: record_id.clone(),
8628        });
8629        if let Some(lease_id) = managed_lease_id {
8630            if let Some(snapshot) = self.managed_worktrees.lock()
8631                .unwrap_or_else(|poisoned| poisoned.into_inner())
8632                .get(&lease_id)
8633                .map(ManagedWorktreeLeaseRecord::snapshot)
8634            {
8635                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8636            }
8637            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8638        }
8639        Ok(())
8640    }
8641
8642    /// Retires `ManagedSessionState::Unavailable` records against the
8643    /// node's configured `session_record_retention` age/keep-N policy.
8644    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8645    /// `Dormant` is resumable and folding it into this sweep would delete
8646    /// records a caller can still act on. Both policy fields default to
8647    /// `0` (disabled); when both are `0` this is a no-op. When both are
8648    /// set, a record is retired only if it fails BOTH tests (older than
8649    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8650    /// workspace); when only one is set, that one alone decides. Called
8651    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8652    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8653    /// loop -- see both call sites.
8654    async fn retire_unavailable_session_records(&self) {
8655        let policy = self.session_record_retention;
8656        let age_enabled = policy.age_ms > 0;
8657        let keep_enabled = policy.keep_per_workspace > 0;
8658        if !age_enabled && !keep_enabled {
8659            return;
8660        }
8661        let now = unix_time_ms();
8662        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8663            self.session_records
8664                .lock()
8665                .unwrap_or_else(|poisoned| poisoned.into_inner())
8666                .records
8667                .values()
8668                .filter(|record| record.state == ManagedSessionState::Unavailable)
8669                .map(|record| {
8670                    (
8671                        record.record_id.clone(),
8672                        record.workspace_id.clone(),
8673                        record.provider.clone(),
8674                        record.updated_at_unix_ms,
8675                    )
8676                })
8677                .collect()
8678        };
8679        if candidates.is_empty() {
8680            return;
8681        }
8682        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8683            BTreeMap::new();
8684        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8685            by_workspace
8686                .entry(workspace_id)
8687                .or_default()
8688                .push((record_id, provider, updated_at_unix_ms));
8689        }
8690        let reason = if age_enabled && keep_enabled {
8691            "age+keep-n"
8692        } else if age_enabled {
8693            "age"
8694        } else {
8695            "keep-n"
8696        };
8697        let mut retired: u64 = 0;
8698        for (workspace_id, mut records) in by_workspace {
8699            // Newest `updated_at_unix_ms` first, so index `>=
8700            // keep_per_workspace` names exactly "beyond the newest N".
8701            records.sort_by(|left, right| right.2.cmp(&left.2));
8702            for (index, (record_id, provider, updated_at_unix_ms)) in
8703                records.into_iter().enumerate()
8704            {
8705                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8706                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8707                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8708                let should_retire = if age_enabled && keep_enabled {
8709                    age_hit && keep_hit
8710                } else {
8711                    age_hit || keep_hit
8712                };
8713                if !should_retire {
8714                    continue;
8715                }
8716                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8717                    Ok(()) => {
8718                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8719                        retired += 1;
8720                        tracing::info!(
8721                            node_id = %self.node_id,
8722                            record_id = %record_id,
8723                            workspace_id = %workspace_id,
8724                            provider = %provider,
8725                            age_ms = record_age_ms,
8726                            reason,
8727                            "managed session record retired"
8728                        );
8729                    }
8730                    Err(error) => tracing::warn!(
8731                        node_id = %self.node_id,
8732                        record_id = %record_id,
8733                        workspace_id = %workspace_id,
8734                        code = ?error.code,
8735                        cause = %error.message,
8736                        "session record retention sweep failed to retire a record"
8737                    ),
8738                }
8739            }
8740        }
8741        if retired > 0 {
8742            let remaining_unavailable = self
8743                .session_records
8744                .lock()
8745                .unwrap_or_else(|poisoned| poisoned.into_inner())
8746                .records
8747                .values()
8748                .filter(|record| record.state == ManagedSessionState::Unavailable)
8749                .count();
8750            tracing::info!(
8751                node_id = %self.node_id,
8752                retired,
8753                remaining_unavailable,
8754                "session record retention sweep completed"
8755            );
8756        }
8757    }
8758
8759    async fn resume_session_record(
8760        &self,
8761        record_id: &SessionRecordId,
8762        terminal_size: gate4agent_types::TerminalSize,
8763        initial_prompt: Option<String>,
8764    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8765        let mut record = self.record(record_id)?;
8766        // claude's ACP agent has no session/load: an ACP claude conversation whose transcript
8767        // is on disk is resumed as a PTY `claude --resume <id>` of that same transcript.
8768        if record.mode == SessionMode::Acp
8769            && record.provider.as_str() == "claude"
8770            && record.provider_session.as_ref().is_some_and(|i| provider_session_exists("claude", &i.id))
8771        {
8772            record.mode = SessionMode::Pty;
8773            if let Some(r) = self.session_records.lock().unwrap_or_else(|p| p.into_inner()).records.get_mut(record_id) {
8774                r.mode = SessionMode::Pty;
8775            }
8776            tracing::info!(record = %record_id, "acp claude record resumed over pty (--resume of its transcript)");
8777        }
8778        if record.active_session.is_some()
8779            || matches!(
8780                record.state,
8781                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8782            )
8783        {
8784            return Err(failure(
8785                NodeFailureCode::SessionRecordBusy,
8786                "managed session already has a live runtime binding",
8787            ));
8788        }
8789        let identity = record.provider_session.clone().ok_or_else(|| {
8790            failure(
8791                NodeFailureCode::SessionRecordNotResumable,
8792                "managed session has no verified provider session identity",
8793            )
8794        })?;
8795        let working_directory = self.workspace_root(&record.workspace_id)?;
8796        if !platform::roots_equal(
8797            &working_directory,
8798            windows_path_text(&record.canonical_root),
8799        ) {
8800            return Err(failure(
8801                NodeFailureCode::SessionRecordConflict,
8802                "managed session workspace root changed; refusing to resume in another directory",
8803            ));
8804        }
8805        {
8806            let records = self
8807                .session_records
8808                .lock()
8809                .unwrap_or_else(|poisoned| poisoned.into_inner());
8810            if records.records.values().any(|candidate| {
8811                candidate.record_id != *record_id
8812                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8813                        session_registry::same_provider_session(
8814                            &candidate.provider,
8815                            candidate_identity,
8816                            &record.provider,
8817                            &identity,
8818                        )
8819                    })
8820                    && (candidate.active_session.is_some()
8821                        || matches!(
8822                            candidate.state,
8823                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8824                        ))
8825            }) {
8826                return Err(failure(
8827                    NodeFailureCode::SessionRecordConflict,
8828                    "provider session is already active through another managed record",
8829                ));
8830            }
8831        }
8832        let runtime_requirement = if initial_prompt.is_some() {
8833            ProviderRuntimeRequirement::ResumeWithPrompt
8834        } else {
8835            ProviderRuntimeRequirement::Resume
8836        };
8837        let runtime_policy = self.admit_provider_runtime(
8838            &record.provider,
8839            runtime_requirement,
8840        )
8841        .await?;
8842        // The resumed runtime runs in the session folder its record was named after.
8843        let working_directory = match self.m4a_accounts.as_ref() {
8844            Some(_) => {
8845                let dir = std::path::Path::new(&working_directory).join(".hatchery-sessions").join(&record.display_name);
8846                if !record.display_name.contains(' ') && dir.is_dir() { dir.to_string_lossy().into_owned() } else { working_directory }
8847            }
8848            None => working_directory,
8849        };
8850        let request = ResumeLaunchRequest {
8851            working_directory,
8852            terminal_size,
8853            initial_prompt,
8854        };
8855        request
8856            .validate()
8857            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8858        self.ensure_binding_capacity()?;
8859        let resolved_materialization = match self.resolve_record_materialization(
8860            record_id,
8861            &record.provider,
8862            record.mode,
8863            record.environment_profile.as_ref(),
8864            record.bundle.as_ref(),
8865            record.context.as_ref(),
8866        ) {
8867            Ok(materialization) => materialization,
8868            Err(error) => {
8869                let label = match error.code {
8870                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8871                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8872                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8873                    _ => "environment-profile-unavailable",
8874                };
8875                self.mark_record_error(record_id, label)?;
8876                return Err(error);
8877            }
8878        };
8879        let (materialization_id, environment_overlay) = match resolved_materialization {
8880            Some((id, overlay)) => (Some(id), overlay),
8881            None => (None, None),
8882        };
8883        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8884        // Resumed runtime: same auto-permit flags and launch options (model / effort) as a spawn.
8885        let environment_overlay = {
8886            let mut args: Vec<OsString> = Vec::new();
8887            if let Ok(cwd) = self.workspace_root(&record.workspace_id) {
8888                let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
8889                if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
8890                crate::auto_permit::prepare(record.provider.as_str(), &homes, Some(std::path::Path::new(&cwd)));
8891            }
8892            args.extend(crate::auto_permit::launch_args(record.provider.as_str(), record.mode));
8893            let options = SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten();
8894            if record.mode == SessionMode::Pty && record.provider.as_str() == "claude" {
8895                if let Some(sel) = options.as_ref() {
8896                    args.push("--model".into()); args.push(sel.model.clone().into());
8897                    if let Some(effort) = sel.values.get("effort").and_then(|v| v.as_str()) {
8898                        args.push("--effort".into()); args.push(effort.to_owned().into());
8899                    }
8900                }
8901            }
8902            if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::supported(record.provider.as_str()) {
8903                if let (Some(file), Some(identity)) = (self.session_resume_journal(), record.provider_session.as_ref()) {
8904                    crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
8905                        instance_id: instance_id.0,
8906                        provider: record.provider.as_str().to_owned(),
8907                        workspace_id: record.workspace_id.as_str().to_owned(),
8908                        provider_session_id: identity.id.clone(),
8909                        session_key: self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned().unwrap_or_default(),
8910                        cwd: String::new(),
8911                        acp: record.mode == SessionMode::Acp,
8912                        options,
8913                        cols: 0,
8914                        rows: 0,
8915                    });
8916                }
8917            }
8918            overlay_with_extra_args(&record.provider, record.mode, environment_overlay, args)?
8919        };
8920        // The resumed runtime keeps the m4a account of the runtime it replaces.
8921        let environment_overlay = match (self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(record.provider.as_str())) {
8922            (Some(accounts), Some(m4a_provider)) if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) => {
8923                if let Some(old) = self.bound_address_for_record(record_id) {
8924                    if let Some(key) = self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&old.session.instance_id).cloned() {
8925                        self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key);
8926                    }
8927                }
8928                let session_key = self.m4a_session_key(instance_id);
8929                self.m4a_session_overlay(accounts, m4a_provider, &record.provider, instance_id, environment_overlay, &session_key, record.mode)?
8930            }
8931            _ => environment_overlay,
8932        };
8933        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8934            instance_id,
8935            &record.provider,
8936            record.mode,
8937            record.environment_profile.as_ref(),
8938        ) {
8939            Ok(selection) => selection,
8940            Err(error) => {
8941                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8942                return Err(error);
8943            }
8944        };
8945        let mut instance_overlay = environment_overlay
8946            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8947            .transpose()?
8948            .flatten();
8949        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8950            self.remove_session(&stale_address).await?;
8951        }
8952        let address = SessionAddress {
8953            workspace_id: record.workspace_id.clone(),
8954            session: SessionKey {
8955                instance_id,
8956                generation: SessionGeneration(1),
8957            },
8958        };
8959        let transaction = self
8960            .state_transaction
8961            .lock()
8962            .unwrap_or_else(|poisoned| poisoned.into_inner());
8963        let current = self.record(record_id)?;
8964        if current.active_session.is_some()
8965            || matches!(
8966                current.state,
8967                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8968            )
8969        {
8970            return Err(failure(
8971                NodeFailureCode::SessionRecordBusy,
8972                "managed session became active while resume was being prepared",
8973            ));
8974        }
8975        self.bind_managed_session_with_materialization(
8976            &address,
8977            record_id.clone(),
8978            runtime_policy,
8979            record.environment_profile.clone(),
8980            record.bundle.clone(),
8981            record.context.clone(),
8982            materialization_id,
8983        );
8984        if let Some(selection) = environment_selection.take() {
8985            selection.retain();
8986        }
8987        if let Some(overlay) = instance_overlay.take() {
8988            overlay.retain();
8989        }
8990        drop(transaction);
8991
8992        let transport = match record.mode {
8993            SessionMode::Pty => TransportKind::Pty,
8994            SessionMode::Inline => TransportKind::Pipe,
8995            SessionMode::Acp => TransportKind::Acp,
8996        };
8997        let agent_id = record.provider.clone();
8998        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8999        if let Err(error) = self
9000            .dispatch_bounded(
9001                ControlCommand::Register {
9002                    instance_id,
9003                    agent_id,
9004                    transport,
9005                },
9006                dispatch_timeout,
9007            )
9008            .await
9009        {
9010            if let Some(binding) = self.remove_binding(&address) {
9011                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
9012            }
9013            return Err(error);
9014        }
9015        let command = self.prepare_command(ControlCommand::Resume {
9016            instance_id,
9017            target: ResumeTarget::ProviderSession { identity },
9018            runtime_policy,
9019            request,
9020        });
9021        let started_after = self.current_sequence();
9022        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
9023            self.rollback_spawn(&address, dispatch_timeout).await?;
9024            return Err(error);
9025        }
9026        if let Err(error) = self.dispatch_envelope(command) {
9027            self.clear_armed_resume(&address);
9028            self.rollback_spawn(&address, dispatch_timeout).await?;
9029            return Err(error);
9030        }
9031        match self
9032            .wait_for_record_resume(&address, started_after)
9033            .await
9034        {
9035            Ok(settled_address) => {
9036                let managed_lease_id = {
9037                    self.managed_worktrees.lock()
9038                        .unwrap_or_else(|poisoned| poisoned.into_inner())
9039                        .lease_for_workspace(&record.workspace_id)
9040                };
9041                if let Some(lease_id) = managed_lease_id {
9042                    {
9043                        let mut bindings = self.session_bindings.lock()
9044                            .unwrap_or_else(|poisoned| poisoned.into_inner());
9045                        let binding = bindings.get_mut(&settled_address.session.instance_id)
9046                            .ok_or_else(|| failure(
9047                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9048                                "resumed managed session lost its exact binding",
9049                            ))?;
9050                        if binding.generation != settled_address.session.generation {
9051                            return Err(failure(
9052                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9053                                "resumed managed session generation diverged",
9054                            ));
9055                        }
9056                        binding.managed_worktree_lease_id = Some(lease_id.clone());
9057                    }
9058                    let snapshot = self.managed_worktrees.lock()
9059                        .unwrap_or_else(|poisoned| poisoned.into_inner())
9060                        .bind_session(
9061                            &lease_id,
9062                            ManagedWorktreeSessionHolder {
9063                                incarnation_id: self.incarnation_id,
9064                                instance_id: settled_address.session.instance_id,
9065                                generation: settled_address.session.generation,
9066                            },
9067                            Some(record_id.clone()),
9068                            unix_time_ms(),
9069                        ).map_err(|message| failure(
9070                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
9071                            &message,
9072                        ))?;
9073                    if let Err(error) = self.persist_state() {
9074                        let _ = self.remove_session(&settled_address).await;
9075                        return Err(persistence_failure(error));
9076                    }
9077                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9078                }
9079                Ok((self.record(record_id)?, settled_address))
9080            }
9081            Err(error) => {
9082                if matches!(
9083                    error.code,
9084                    NodeFailureCode::SessionRecordNotResumable
9085                        | NodeFailureCode::BackendOperationFailed
9086                ) {
9087                    if let Some(bound) = self.bound_address_for_record(record_id) {
9088                        let _ = self.remove_session(&bound).await;
9089                    }
9090                }
9091                Err(error)
9092            }
9093        }
9094    }
9095
9096    async fn wait_for_record_resume(
9097        &self,
9098        initial_address: &SessionAddress,
9099        after_sequence: u64,
9100    ) -> Result<SessionAddress, NodeFailure> {
9101        let deadline = Instant::now()
9102            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
9103        let mut scan_after = after_sequence;
9104        loop {
9105            let events = self
9106                .history
9107                .lock()
9108                .unwrap_or_else(|poisoned| poisoned.into_inner())
9109                .events
9110                .iter()
9111                .filter(|event| event.sequence > scan_after)
9112                .cloned()
9113                .collect::<Vec<_>>();
9114            for envelope in events {
9115                scan_after = scan_after.max(envelope.sequence);
9116                let NodeEvent::Control { address, event } = envelope.event else {
9117                    continue;
9118                };
9119                if address.session.instance_id != initial_address.session.instance_id {
9120                    continue;
9121                }
9122                match event.event {
9123                    ControlEventKind::Resumed { .. } => return Ok(address),
9124                    ControlEventKind::ResumeDenied { reason } => {
9125                        return Err(failure(
9126                            NodeFailureCode::SessionRecordNotResumable,
9127                            &reason,
9128                        ));
9129                    }
9130                    ControlEventKind::ResumeFailed { message }
9131                    | ControlEventKind::CommandRejected { message } => {
9132                        return Err(failure(
9133                            NodeFailureCode::BackendOperationFailed,
9134                            &message,
9135                        ));
9136                    }
9137                    _ => {}
9138                }
9139            }
9140            if Instant::now() >= deadline {
9141                return Err(failure(
9142                    NodeFailureCode::BackendBusy,
9143                    "managed resume did not settle before the bounded deadline",
9144                ));
9145            }
9146            sleep(Duration::from_millis(5)).await;
9147        }
9148    }
9149
9150    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
9151        self.workspaces
9152            .read()
9153            .unwrap_or_else(|poisoned| poisoned.into_inner())
9154            .get(workspace_id)
9155            .cloned()
9156            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
9157    }
9158
9159    fn workspace_roots(&self) -> Vec<PathBuf> {
9160        self.workspaces
9161            .read()
9162            .unwrap_or_else(|poisoned| poisoned.into_inner())
9163            .values()
9164            .map(PathBuf::from)
9165            .collect()
9166    }
9167
9168    async fn inspect_workspace(
9169        &self,
9170        workspace_id: WorkspaceId,
9171    ) -> Result<WorkspaceInspection, NodeFailure> {
9172        let _permit = self
9173            .inspection_slots
9174            .clone()
9175            .try_acquire_owned()
9176            .map_err(|_| {
9177                tracing::warn!(
9178                    workspace_id = %workspace_id,
9179                    "workspace inspection rejected: inspection capacity is busy",
9180                );
9181                failure(
9182                    NodeFailureCode::BackendBusy,
9183                    "workspace inspection capacity is busy",
9184                )
9185            })?;
9186        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
9187            tracing::warn!(
9188                workspace_id = %workspace_id,
9189                code = ?error.code,
9190                "workspace inspection rejected: workspace is not registered",
9191            );
9192            error
9193        })?;
9194        let tree_root = canonical_root.clone();
9195        let started_at = Instant::now();
9196        let time_budget_ms = workspace_inspection_time_budget_ms();
9197        let entry_cap = workspace_inspection_entry_cap();
9198        let deadline = started_at + Duration::from_millis(time_budget_ms);
9199        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
9200            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
9201        })
9202        .await
9203        .map_err(|error| {
9204            tracing::warn!(
9205                workspace_id = %workspace_id,
9206                error = %error,
9207                "workspace inspection rejected: walk task failed",
9208            );
9209            failure(
9210                NodeFailureCode::BackendOperationFailed,
9211                &format!("workspace inspection task failed: {error}"),
9212            )
9213        })?;
9214        let (mut git, git_time_budget_exceeded) =
9215            inspect_git_workspace(&canonical_root, deadline).await;
9216        if !git.worktrees.is_empty() {
9217            let registered = self
9218                .workspaces
9219                .read()
9220                .unwrap_or_else(|poisoned| poisoned.into_inner())
9221                .iter()
9222                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
9223                .collect::<Vec<_>>();
9224            for worktree in &mut git.worktrees {
9225                worktree.workspace_id = registered
9226                    .iter()
9227                    .find(|(_, root)| {
9228                        worktree_paths_equal(root, windows_path_text(&worktree.path))
9229                    })
9230                    .map(|(workspace_id, _)| workspace_id.clone());
9231            }
9232        }
9233        if git.is_repository {
9234            git.managed_worktree = self
9235                .managed_worktrees
9236                .lock()
9237                .unwrap_or_else(|poisoned| poisoned.into_inner())
9238                .git_scope(&workspace_id, git.branch.as_deref());
9239        }
9240        let truncation = workspace_inspection_truncation(
9241            &walk_budget,
9242            git_time_budget_exceeded,
9243            started_at.elapsed(),
9244        );
9245        if let Some(truncation) = &truncation {
9246            tracing::info!(
9247                workspace_id = %workspace_id,
9248                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
9249                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
9250                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
9251                entries_visited = truncation.entries_visited,
9252                elapsed_ms = truncation.elapsed_ms,
9253                "workspace inspection truncated by its inner budget",
9254            );
9255        }
9256        Ok(WorkspaceInspection {
9257            workspace_id,
9258            entries,
9259            tree_truncated,
9260            git,
9261            truncation,
9262        })
9263    }
9264
9265    async fn browse_host_directories(
9266        &self,
9267        directory: Option<OpaqueHostPath>,
9268        after: Option<OpaqueHostPath>,
9269    ) -> Result<HostDirectoryListing, NodeFailure> {
9270        let permit = self
9271            .inspection_slots
9272            .clone()
9273            .try_acquire_owned()
9274            .map_err(|_| {
9275                failure(
9276                    NodeFailureCode::BackendBusy,
9277                    "host directory browse capacity is busy",
9278                )
9279            })?;
9280        let task = tokio::task::spawn_blocking(move || {
9281            let _permit = permit;
9282            browse_host_directories(directory, after)
9283        });
9284        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
9285            .await
9286            .map_err(|_| {
9287                failure(
9288                    NodeFailureCode::HostDirectoryReadTimedOut,
9289                    "host directory browse exceeded its bounded deadline",
9290                )
9291            })?
9292            .map_err(|_| {
9293                failure(
9294                    NodeFailureCode::HostDirectoryReadFailed,
9295                    "host directory browse task failed",
9296                )
9297            })?
9298            .map_err(host_directory_failure)
9299    }
9300
9301    async fn read_workspace_file(
9302        &self,
9303        workspace_id: WorkspaceId,
9304        path: RepositoryPath,
9305    ) -> Result<WorkspaceFileRead, NodeFailure> {
9306        let canonical_root = self.workspace_root(&workspace_id)?;
9307        let permit = self
9308            .inspection_slots
9309            .clone()
9310            .try_acquire_owned()
9311            .map_err(|_| {
9312                failure(
9313                    NodeFailureCode::BackendBusy,
9314                    "workspace file read capacity is busy",
9315                )
9316            })?;
9317        let path_to_read = path.clone();
9318        let task = tokio::task::spawn_blocking(move || {
9319            let _permit = permit;
9320            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
9321        });
9322        let content = timeout(
9323            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
9324            task,
9325        )
9326        .await
9327        .map_err(|_| {
9328            failure(
9329                NodeFailureCode::RepositoryFileReadTimedOut,
9330                "repository file read exceeded its bounded deadline",
9331            )
9332        })?
9333        .map_err(|_| {
9334            failure(
9335                NodeFailureCode::RepositoryFileReadFailed,
9336                "repository file read task failed",
9337            )
9338        })?
9339        .map_err(workspace_file_failure)?;
9340        let content = match content {
9341            WorkspaceFileBytes::Utf8(text) => {
9342                let revision = Some(workspace_file_revision(text.as_bytes()));
9343                return Ok(WorkspaceFileRead {
9344                    workspace_id,
9345                    path,
9346                    content: WorkspaceFileContent::Utf8 {
9347                        byte_len: u32::try_from(text.len())
9348                            .expect("bounded workspace text length must fit u32"),
9349                        text,
9350                    },
9351                    revision,
9352                });
9353            }
9354            WorkspaceFileBytes::NonUtf8 { byte_length } => {
9355                WorkspaceFileContent::NonUtf8 {
9356                    byte_len: u32::try_from(byte_length)
9357                        .expect("bounded workspace file length must fit u32"),
9358                }
9359            }
9360            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9361                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9362            },
9363        };
9364        Ok(WorkspaceFileRead {
9365            workspace_id,
9366            path,
9367            content,
9368            revision: None,
9369        })
9370    }
9371
9372    async fn write_workspace_file(
9373        &self,
9374        workspace_id: WorkspaceId,
9375        path: RepositoryPath,
9376        expected_revision: WorkspaceFileRevision,
9377        text: String,
9378    ) -> Result<WorkspaceFileRead, NodeFailure> {
9379        let canonical_root = self.workspace_root(&workspace_id)?;
9380        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9381            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9382        })?;
9383        let path_to_write = path.clone();
9384        let expected = expected_revision.as_str().to_owned();
9385        let text_to_write = text.clone();
9386        let task = tokio::task::spawn_blocking(move || {
9387            let _permit = permit;
9388            write_workspace_file_to_disk(
9389                Path::new(&canonical_root),
9390                &path_to_write,
9391                &expected,
9392                &text_to_write,
9393            )
9394        });
9395        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9396            .await
9397            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9398            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9399            .map_err(workspace_file_write_failure)?;
9400        Ok(WorkspaceFileRead {
9401            workspace_id,
9402            path,
9403            content: WorkspaceFileContent::Utf8 {
9404                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9405                text,
9406            },
9407            revision: Some(WorkspaceFileRevision::new(revision)
9408                .expect("workspace writer returns a valid SHA-256 revision")),
9409        })
9410    }
9411
9412    async fn create_workspace_file(
9413        &self,
9414        workspace_id: WorkspaceId,
9415        path: RepositoryPath,
9416    ) -> Result<WorkspaceFileRead, NodeFailure> {
9417        let canonical_root = self.workspace_root(&workspace_id)?;
9418        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9419            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9420        })?;
9421        let path_to_create = path.clone();
9422        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9423        let task_commit_state = Arc::clone(&commit_state);
9424        let task = tokio::task::spawn_blocking(move || {
9425            let _permit = permit;
9426            create_workspace_file_on_disk(
9427                Path::new(&canonical_root),
9428                &path_to_create,
9429                &task_commit_state,
9430            )
9431        });
9432        let revision = settle_workspace_entry_create(
9433            task,
9434            commit_state,
9435            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9436            "repository file create exceeded its bounded deadline",
9437            "repository file create task failed",
9438        )
9439        .await?;
9440        Ok(WorkspaceFileRead {
9441            workspace_id,
9442            path,
9443            content: WorkspaceFileContent::Utf8 {
9444                text: String::new(),
9445                byte_len: 0,
9446            },
9447            revision: Some(WorkspaceFileRevision::new(revision)
9448                .expect("workspace creator returns a valid SHA-256 revision")),
9449        })
9450    }
9451
9452    async fn create_workspace_directory(
9453        &self,
9454        workspace_id: WorkspaceId,
9455        path: RepositoryPath,
9456    ) -> Result<WorkspaceEntry, NodeFailure> {
9457        let canonical_root = self.workspace_root(&workspace_id)?;
9458        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9459            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9460        })?;
9461        let path_to_create = path.clone();
9462        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9463        let task_commit_state = Arc::clone(&commit_state);
9464        let task = tokio::task::spawn_blocking(move || {
9465            let _permit = permit;
9466            create_workspace_directory_on_disk(
9467                Path::new(&canonical_root),
9468                &path_to_create,
9469                &task_commit_state,
9470            )
9471        });
9472        settle_workspace_entry_create(
9473            task,
9474            commit_state,
9475            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9476            "repository directory create exceeded its bounded deadline",
9477            "repository directory create task failed",
9478        )
9479        .await?;
9480        Ok(WorkspaceEntry {
9481            relative_path: path,
9482            kind: WorkspaceEntryKind::Directory,
9483        })
9484    }
9485
9486    async fn read_git_history(
9487        &self,
9488        workspace_id: WorkspaceId,
9489        path: Option<RepositoryPath>,
9490        before: Option<GitObjectId>,
9491        limit: u16,
9492    ) -> Result<GitHistoryPage, NodeFailure> {
9493        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9494            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9495        })?;
9496        let root = self.workspace_root(&workspace_id)?;
9497        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9498    }
9499
9500    async fn read_git_diff(
9501        &self,
9502        workspace_id: WorkspaceId,
9503        request: GitDiffRequest,
9504    ) -> Result<GitDiff, NodeFailure> {
9505        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9506            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9507        })?;
9508        let root = self.workspace_root(&workspace_id)?;
9509        read_git_diff_bounded(&root, request).await
9510    }
9511
9512    fn native_session_route_root(
9513        &self,
9514        route: &NativeSessionCatalogRoute,
9515    ) -> Result<Option<String>, NodeFailure> {
9516        route
9517            .validate()
9518            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9519        match route.scope {
9520            gate4agent_types::NativeSessionCatalogScope::Workspace => route
9521                .workspace_id
9522                .as_ref()
9523                .map(|workspace_id| self.workspace_root(workspace_id))
9524                .transpose(),
9525            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9526        }
9527    }
9528
9529    fn project_native_session_entries(
9530        &self,
9531        route: &NativeSessionCatalogRoute,
9532        entries: Vec<ScopedNativeSessionCatalogEntry>,
9533    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9534        let current_root = route
9535            .workspace_id
9536            .as_ref()
9537            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9538        let records = self
9539            .session_records
9540            .lock()
9541            .unwrap_or_else(|poisoned| poisoned.into_inner());
9542        let mut seen_records = std::collections::HashSet::new();
9543        entries
9544            .into_iter()
9545            .map(|entry| {
9546                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9547                    let matches = records
9548                        .records
9549                        .values()
9550                        .filter(|record| {
9551                            record.provider == route.provider
9552                                && record.workspace_id == *workspace_id
9553                                && current_root.as_deref().is_some_and(|root| {
9554                                    platform::roots_equal(
9555                                        root,
9556                                        windows_path_text(&record.canonical_root),
9557                                    )
9558                                })
9559                                && record.provider_session.as_ref().is_some_and(|identity| {
9560                                    session_registry::same_provider_session(
9561                                        &record.provider,
9562                                        identity,
9563                                        &route.provider,
9564                                        &entry.provider_session,
9565                                    )
9566                                })
9567                        })
9568                        .map(|record| record.record_id.clone())
9569                        .collect::<Vec<_>>();
9570                    (matches.len() == 1
9571                        && seen_records.insert(matches[0].clone()))
9572                        .then(|| matches[0].clone())
9573                });
9574                let metadata = entry.metadata;
9575                crate::protocol::NativeSessionCatalogEntry {
9576                    selection_id: metadata.selection_id,
9577                    title: metadata.title,
9578                    modified_at_unix_ms: metadata.modified_at_unix_ms,
9579                    model: metadata.model,
9580                    message_count: metadata.message_count,
9581                    completed_turn_count: metadata.completed_turn_count,
9582                    external_group: entry.external_group,
9583                    record_id,
9584                }
9585            })
9586            .collect()
9587    }
9588
9589    async fn catalog_native_sessions(
9590        &self,
9591        route: NativeSessionCatalogRoute,
9592        limit: u16,
9593    ) -> Result<(
9594        Vec<crate::protocol::NativeSessionCatalogEntry>,
9595        crate::protocol::NativeSessionCatalogSummary,
9596    ), NodeFailure> {
9597        let canonical_root = self.native_session_route_root(&route)?;
9598        let registered_workspace_roots = self.workspace_roots();
9599        let provider = route.provider.clone();
9600        let scope = route.scope;
9601        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9602            failure(
9603                NodeFailureCode::InvalidRequest,
9604                "native session catalog is not configured",
9605            )
9606        })?;
9607        let _permit = Arc::clone(&self.inspection_slots)
9608            .try_acquire_owned()
9609            .map_err(|_| {
9610                failure(
9611                    NodeFailureCode::BackendBusy,
9612                    "native session catalog capacity is busy",
9613                )
9614            })?;
9615        let page = run_native_session_catalog_operation(
9616            catalog,
9617            "native session catalog",
9618            move |catalog| {
9619                catalog.catalog_initial_for_scope(
9620                    &provider,
9621                    scope,
9622                    canonical_root.as_deref().map(Path::new),
9623                    &registered_workspace_roots,
9624                    limit,
9625                )
9626            },
9627        )
9628        .await?;
9629        let summary = crate::protocol::NativeSessionCatalogSummary {
9630            catalog_revision: page.revision,
9631            recent_cutoff_unix_ms: page.cutoff_unix_ms,
9632            recent_total_count: page.recent_total_count,
9633            older_total_count: page.older_total_count,
9634            recent_next_after_selection_id: page.next_after_selection_id,
9635            recent_has_more: page.remaining_count > 0,
9636        };
9637        Ok((self.project_native_session_entries(&route, page.entries), summary))
9638    }
9639
9640    async fn page_native_sessions(
9641        &self,
9642        route: NativeSessionCatalogRoute,
9643        window: crate::protocol::NativeSessionCatalogWindow,
9644        catalog_revision: u64,
9645        recent_cutoff_unix_ms: u64,
9646        after_selection_id: Option<String>,
9647        limit: u16,
9648    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9649        let canonical_root = self.native_session_route_root(&route)?;
9650        let registered_workspace_roots = self.workspace_roots();
9651        let provider = route.provider.clone();
9652        let scope = route.scope;
9653        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9654            failure(
9655                NodeFailureCode::InvalidRequest,
9656                "native session catalog is not configured",
9657            )
9658        })?;
9659        let _permit = Arc::clone(&self.inspection_slots)
9660            .try_acquire_owned()
9661            .map_err(|_| {
9662                failure(
9663                    NodeFailureCode::BackendBusy,
9664                    "native session catalog capacity is busy",
9665                )
9666            })?;
9667        let page = run_native_session_catalog_operation(
9668            catalog,
9669            "native session catalog page",
9670            move |catalog| {
9671                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9672                    &provider,
9673                    scope,
9674                    canonical_root.as_deref().map(Path::new),
9675                    &registered_workspace_roots,
9676                    window,
9677                    catalog_revision,
9678                    recent_cutoff_unix_ms,
9679                    after_selection_id.as_deref(),
9680                    limit,
9681                ))
9682            },
9683        )
9684        .await?;
9685        let page = page.map_err(|error| match error {
9686            NativeSessionCatalogError::StaleCatalog => failure(
9687                NodeFailureCode::StaleNativeSessionCatalog,
9688                "native session catalog revision or cursor is stale",
9689            ),
9690            _ => failure(
9691                NodeFailureCode::BackendOperationFailed,
9692                "native session catalog page failed",
9693            ),
9694        })?;
9695        let entries = self.project_native_session_entries(&route, page.entries);
9696        Ok(crate::protocol::NativeSessionCatalogPage {
9697            window: page.window,
9698            revision: page.revision,
9699            entries,
9700            next_after_selection_id: page.next_after_selection_id,
9701            remaining_count: page.remaining_count,
9702            has_more: page.remaining_count > 0,
9703        })
9704    }
9705
9706    async fn preview_native_session(
9707        &self,
9708        selection: NativeSessionSelection,
9709        message_limit: u16,
9710    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9711        selection
9712            .validate()
9713            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9714        let canonical_root = self.native_session_route_root(&selection.route)?;
9715        let registered_workspace_roots = self.workspace_roots();
9716        let provider = selection.route.provider.clone();
9717        let scope = selection.route.scope;
9718        let catalog_revision = selection.catalog_revision;
9719        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9720        let selection_id = selection.selection_id.clone();
9721        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9722            failure(
9723                NodeFailureCode::InvalidRequest,
9724                "native session preview is not configured",
9725            )
9726        })?;
9727        let _permit = Arc::clone(&self.inspection_slots)
9728            .try_acquire_owned()
9729            .map_err(|_| {
9730                failure(
9731                    NodeFailureCode::BackendBusy,
9732                    "native session preview capacity is busy",
9733                )
9734            })?;
9735        let preview = run_native_session_catalog_operation(
9736            catalog,
9737            "native session preview",
9738            move |catalog| {
9739                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9740                    &provider,
9741                    scope,
9742                    canonical_root.as_deref().map(Path::new),
9743                    &registered_workspace_roots,
9744                    catalog_revision,
9745                    recent_cutoff_unix_ms,
9746                    &selection_id,
9747                    message_limit,
9748                ))
9749            },
9750        )
9751        .await?;
9752        preview
9753            .map(Into::into)
9754            .map_err(native_session_preview_failure)
9755    }
9756
9757    async fn index_native_session(
9758        &self,
9759        selection: NativeSessionSelection,
9760        display_name: String,
9761    ) -> Result<ManagedSessionRecord, NodeFailure> {
9762        selection
9763            .validate()
9764            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9765        if selection.route.scope
9766            != gate4agent_types::NativeSessionCatalogScope::Workspace
9767        {
9768            return Err(failure(
9769                NodeFailureCode::WorkspaceRegistrationRequired,
9770                "register the external project as a workspace before indexing or resuming it",
9771            ));
9772        }
9773        let workspace_id = selection
9774            .route
9775            .workspace_id
9776            .clone()
9777            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9778        let canonical_root = self
9779            .native_session_route_root(&selection.route)?
9780            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9781        let registered_workspace_roots = self.workspace_roots();
9782        let provider = selection.route.provider.clone();
9783        let catalog_revision = selection.catalog_revision;
9784        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9785        let selection_id = selection.selection_id.clone();
9786        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9787            failure(
9788                NodeFailureCode::InvalidRequest,
9789                "native session index is not configured",
9790            )
9791        })?;
9792        let _permit = Arc::clone(&self.inspection_slots)
9793            .try_acquire_owned()
9794            .map_err(|_| {
9795                failure(
9796                    NodeFailureCode::BackendBusy,
9797                    "native session index capacity is busy",
9798                )
9799            })?;
9800        let resolved = run_native_session_catalog_operation(
9801            catalog,
9802            "native session index resolution",
9803            move |catalog| {
9804                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9805                    &provider,
9806                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9807                    Some(Path::new(&canonical_root)),
9808                    &registered_workspace_roots,
9809                    catalog_revision,
9810                    recent_cutoff_unix_ms,
9811                    &selection_id,
9812                ))
9813            },
9814        )
9815        .await?
9816        .map_err(native_session_preview_failure)?;
9817        self.index_provider_session_with_policy(
9818            workspace_id,
9819            selection.route.provider,
9820            resolved.identity,
9821            display_name,
9822            true,
9823        )
9824    }
9825
9826    async fn preview_native_session_by_session_id(
9827        &self,
9828        workspace_id: WorkspaceId,
9829        provider: AgentId,
9830        session_id: String,
9831        message_limit: u16,
9832    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9833        let canonical_root = self.workspace_root(&workspace_id)?;
9834        let registered_workspace_roots = self.workspace_roots();
9835        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9836            failure(
9837                NodeFailureCode::InvalidRequest,
9838                "native session preview is not configured",
9839            )
9840        })?;
9841        let _permit = Arc::clone(&self.inspection_slots)
9842            .try_acquire_owned()
9843            .map_err(|_| {
9844                failure(
9845                    NodeFailureCode::BackendBusy,
9846                    "native session preview capacity is busy",
9847                )
9848            })?;
9849        run_native_session_catalog_operation(
9850            catalog,
9851            "native session preview",
9852            move |catalog| {
9853                catalog.preview_session_id_for_workspace(
9854                    &provider,
9855                    Path::new(&canonical_root),
9856                    &registered_workspace_roots,
9857                    &session_id,
9858                    message_limit,
9859                )
9860            },
9861        )
9862        .await
9863    }
9864
9865    async fn register_workspace(
9866        &self,
9867        workspace_id: WorkspaceId,
9868        root: String,
9869    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9870        validate_workspace_request_root(&root)?;
9871        let canonicalize_workspace_id = workspace_id.clone();
9872        let workspace = tokio::task::spawn_blocking(move || {
9873            WorkspaceConfig::new(canonicalize_workspace_id, root)
9874        })
9875        .await
9876        .map_err(|error| {
9877            failure(
9878                NodeFailureCode::BackendOperationFailed,
9879                &format!("workspace canonicalization task failed: {error}"),
9880            )
9881        })?
9882        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9883        let transaction = self
9884            .state_transaction
9885            .lock()
9886            .unwrap_or_else(|poisoned| poisoned.into_inner());
9887        let mut workspaces = self
9888            .workspaces
9889            .write()
9890            .unwrap_or_else(|poisoned| poisoned.into_inner());
9891        if workspaces.contains_key(&workspace_id) {
9892            return Err(failure(
9893                NodeFailureCode::DuplicateWorkspaceId,
9894                "workspace ID is already registered",
9895            ));
9896        }
9897        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9898            platform::roots_equal(existing_root, workspace.canonical_root())
9899        }) {
9900            return Err(failure(
9901                NodeFailureCode::DuplicateWorkspaceRoot,
9902                &format!("workspace root is already registered as '{existing_id}'"),
9903            ));
9904        }
9905        {
9906            let records = self
9907                .session_records
9908                .lock()
9909                .unwrap_or_else(|poisoned| poisoned.into_inner());
9910            if let Some(record) = records.records.values().find(|record| {
9911                record.workspace_id == workspace_id
9912                    && !record
9913                        .canonical_root
9914                        .as_utf8()
9915                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9916            }) {
9917                return Err(failure(
9918                    NodeFailureCode::SessionRecordConflict,
9919                    &format!(
9920                        "workspace ID is retained by session record '{}' at another root",
9921                        record.record_id,
9922                    ),
9923                ));
9924            }
9925        }
9926        let snapshot = WorkspaceSnapshot {
9927            workspace_id: workspace_id.clone(),
9928            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9929            sessions: Vec::new(),
9930            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9931            managed_worktree_profiles: Some(WorktreeProfileInventory {
9932                profiles: Vec::new(),
9933            }),
9934        };
9935        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9936        drop(workspaces);
9937        let previous_records = {
9938            let mut records = self
9939                .session_records
9940                .lock()
9941                .unwrap_or_else(|poisoned| poisoned.into_inner());
9942            let previous = records.records.clone();
9943            for record in records.records.values_mut().filter(|record| {
9944                record.workspace_id == workspace_id
9945                    && record
9946                        .canonical_root
9947                        .as_utf8()
9948                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9949            }) {
9950                if record.active_session.is_none() {
9951                    record.state = detached_record_state(record);
9952                }
9953                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9954                    record.last_error = None;
9955                }
9956                record.updated_at_unix_ms = unix_time_ms();
9957            }
9958            previous
9959        };
9960        if let Err(error) = self.persist_state_locked() {
9961            self.workspaces
9962                .write()
9963                .unwrap_or_else(|poisoned| poisoned.into_inner())
9964                .remove(&workspace_id);
9965            self.session_records
9966                .lock()
9967                .unwrap_or_else(|poisoned| poisoned.into_inner())
9968                .records = previous_records;
9969            return Err(persistence_failure(error));
9970        }
9971        let restored = self
9972            .session_records
9973            .lock()
9974            .unwrap_or_else(|poisoned| poisoned.into_inner())
9975            .records
9976            .values()
9977            .filter(|record| record.workspace_id == workspace_id)
9978            .cloned()
9979            .collect::<Vec<_>>();
9980        drop(transaction);
9981        self.publish(NodeEvent::WorkspaceAdded {
9982            workspace: snapshot.clone(),
9983        });
9984        for record in restored {
9985            self.publish_record(record);
9986        }
9987        Ok(snapshot)
9988    }
9989
9990    async fn create_standalone_workspace(
9991        &self,
9992        workspace_id: WorkspaceId,
9993        root: String,
9994        initial_branch: Option<String>,
9995    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9996        validate_workspace_request_root(&root)?;
9997        validate_git_revision("initial branch", initial_branch.as_deref())?;
9998        let preflight_workspace_id = workspace_id.clone();
9999        let preflight_root = root.clone();
10000        let canonical_candidate = tokio::task::spawn_blocking(move || {
10001            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
10002        })
10003        .await
10004        .map_err(|_| failure(
10005            NodeFailureCode::BackendOperationFailed,
10006            "standalone workspace root preflight task failed",
10007        ))?
10008        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
10009        {
10010            let workspaces = self
10011                .workspaces
10012                .read()
10013                .unwrap_or_else(|poisoned| poisoned.into_inner());
10014            if workspaces.contains_key(&workspace_id) {
10015                return Err(failure(
10016                    NodeFailureCode::DuplicateWorkspaceId,
10017                    "workspace ID is already registered",
10018                ));
10019            }
10020            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
10021                platform::roots_equal(existing_root, &canonical_candidate)
10022            }) {
10023                return Err(failure(
10024                    NodeFailureCode::DuplicateWorkspaceRoot,
10025                    &format!("workspace root is already registered as '{existing_id}'"),
10026                ));
10027            }
10028        }
10029        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
10030            .await
10031            .map_err(standalone_workspace_failure)?;
10032        prepared
10033            .verify_for_registration()
10034            .await
10035            .map_err(standalone_workspace_failure)?;
10036        match self
10037            .register_workspace(workspace_id, prepared.root().to_owned())
10038            .await
10039        {
10040            Ok(workspace) => Ok(workspace),
10041            Err(registration_error) => {
10042                if prepared.compensate_registration_failure().await {
10043                    Err(registration_error)
10044                } else {
10045                    Err(failure(
10046                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
10047                        "standalone workspace initialization succeeded but registration failed; recovery is required",
10048                    ))
10049                }
10050            }
10051        }
10052    }
10053
10054    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
10055        let bindings = self
10056            .session_bindings
10057            .lock()
10058            .unwrap_or_else(|poisoned| poisoned.into_inner());
10059        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
10060            return Err(failure(
10061                NodeFailureCode::WorkspaceBusy,
10062                "workspace retains a bound session; remove every session before unregistering it",
10063            ));
10064        }
10065        drop(bindings);
10066        let transaction = self
10067            .state_transaction
10068            .lock()
10069            .unwrap_or_else(|poisoned| poisoned.into_inner());
10070        let mut workspaces = self
10071            .workspaces
10072            .write()
10073            .unwrap_or_else(|poisoned| poisoned.into_inner());
10074        if !workspaces.contains_key(workspace_id) {
10075            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10076        }
10077        if workspaces.len() == 1 {
10078            return Err(failure(
10079                NodeFailureCode::LastWorkspace,
10080                "node must retain at least one workspace",
10081            ));
10082        }
10083        let removed_root = workspaces
10084            .remove(workspace_id)
10085            .expect("workspace presence was validated");
10086        drop(workspaces);
10087        let previous_records = {
10088            let mut records = self
10089                .session_records
10090                .lock()
10091                .unwrap_or_else(|poisoned| poisoned.into_inner());
10092            let previous = records.records.clone();
10093            for record in records
10094                .records
10095                .values_mut()
10096                .filter(|record| &record.workspace_id == workspace_id)
10097            {
10098                record.active_session = None;
10099                record.state = ManagedSessionState::Unavailable;
10100                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
10101                record.updated_at_unix_ms = unix_time_ms();
10102            }
10103            previous
10104        };
10105        if let Err(error) = self.persist_state_locked() {
10106            self.workspaces
10107                .write()
10108                .unwrap_or_else(|poisoned| poisoned.into_inner())
10109                .insert(workspace_id.clone(), removed_root);
10110            self.session_records
10111                .lock()
10112                .unwrap_or_else(|poisoned| poisoned.into_inner())
10113                .records = previous_records;
10114            return Err(persistence_failure(error));
10115        }
10116        let unavailable = self
10117            .session_records
10118            .lock()
10119            .unwrap_or_else(|poisoned| poisoned.into_inner())
10120            .records
10121            .values()
10122            .filter(|record| &record.workspace_id == workspace_id)
10123            .cloned()
10124            .collect::<Vec<_>>();
10125        drop(transaction);
10126        self.publish(NodeEvent::WorkspaceRemoved {
10127            workspace_id: workspace_id.clone(),
10128        });
10129        for record in unavailable {
10130            self.publish_record(record);
10131        }
10132        Ok(())
10133    }
10134
10135    async fn create_worktree(
10136        &self,
10137        source_workspace_id: WorkspaceId,
10138        workspace_id: WorkspaceId,
10139        target_root: String,
10140        branch: String,
10141        base: Option<String>,
10142    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
10143        self.require_worktree_service(&source_workspace_id)?;
10144        validate_workspace_request_root(&target_root)?;
10145        validate_git_revision("worktree base", base.as_deref())?;
10146        let source_root = self.workspace_root(&source_workspace_id)?;
10147        {
10148            let workspaces = self
10149                .workspaces
10150                .read()
10151                .unwrap_or_else(|poisoned| poisoned.into_inner());
10152            if workspaces.contains_key(&workspace_id) {
10153                return Err(failure(
10154                    NodeFailureCode::DuplicateWorkspaceId,
10155                    "workspace ID is already registered",
10156                ));
10157            }
10158            if let Some((existing_id, _)) = workspaces
10159                .iter()
10160                .find(|(_, root)| worktree_paths_equal(root, &target_root))
10161            {
10162                return Err(failure(
10163                    NodeFailureCode::DuplicateWorkspaceRoot,
10164                    &format!("workspace root is already registered as '{existing_id}'"),
10165                ));
10166            }
10167        }
10168        let mut worktree = create_git_worktree(
10169            &source_root,
10170            &target_root,
10171            &branch,
10172            base.as_deref(),
10173        )
10174        .await
10175        .map_err(git_worktree_failure)?;
10176        let workspace = match self
10177            .register_workspace(workspace_id, worktree.path.clone())
10178            .await
10179        {
10180            Ok(workspace) => workspace,
10181            Err(registration_error) => {
10182                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
10183                    return Err(failure(
10184                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
10185                        "worktree registration failed and compensation requires recovery",
10186                    ));
10187                }
10188                return Err(failure(
10189                    registration_error.code,
10190                    &registration_error.message,
10191                ));
10192            }
10193        };
10194        worktree.workspace_id = Some(workspace.workspace_id.clone());
10195        Ok((protocol_worktree(worktree), workspace))
10196    }
10197
10198    async fn remove_worktree(
10199        &self,
10200        source_workspace_id: WorkspaceId,
10201        target_root: String,
10202    ) -> Result<Option<WorkspaceId>, NodeFailure> {
10203        self.require_worktree_service(&source_workspace_id)?;
10204        validate_workspace_request_root(&target_root)?;
10205        let source_root = self.workspace_root(&source_workspace_id)?;
10206        let target_root = normalize_worktree_removal_target(&target_root)
10207            .map_err(git_worktree_failure)?;
10208        let target = list_git_worktrees(&source_root)
10209            .await
10210            .map_err(git_worktree_failure)?
10211            .into_iter()
10212            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
10213            .ok_or_else(|| {
10214                failure(
10215                    NodeFailureCode::WorktreeProtected,
10216                    "refusing to remove a path that is not in Git's worktree listing",
10217                )
10218            })?;
10219        let registered_workspace_id = {
10220            let workspaces = self
10221                .workspaces
10222                .read()
10223                .unwrap_or_else(|poisoned| poisoned.into_inner());
10224            let matched = workspaces
10225                .iter()
10226                .find(|(_, root)| worktree_paths_equal(root, &target.path))
10227                .map(|(workspace_id, _)| workspace_id.clone());
10228            if matched.is_some() && workspaces.len() == 1 {
10229                return Err(failure(
10230                    NodeFailureCode::LastWorkspace,
10231                    "node must retain at least one workspace",
10232                ));
10233            }
10234            matched
10235        };
10236        if let Some(workspace_id) = registered_workspace_id.as_ref() {
10237            let bindings = self
10238                .session_bindings
10239                .lock()
10240                .unwrap_or_else(|poisoned| poisoned.into_inner());
10241            if bindings
10242                .values()
10243                .any(|binding| &binding.workspace_id == workspace_id)
10244            {
10245                return Err(failure(
10246                    NodeFailureCode::WorkspaceBusy,
10247                    "worktree workspace retains a bound session; remove every session first",
10248                ));
10249            }
10250        }
10251        remove_git_worktree(&source_root, &target.path)
10252            .await
10253            .map_err(git_worktree_failure)?;
10254        if let Some(workspace_id) = registered_workspace_id.as_ref() {
10255            self.unregister_workspace(workspace_id)
10256                .map_err(|error| failure(error.code, &error.message))?;
10257        }
10258        Ok(registered_workspace_id)
10259    }
10260
10261    async fn cleanup_managed_worktree(
10262        &self,
10263        lease_id: &ManagedWorktreeLeaseId,
10264        explicit: bool,
10265    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
10266        let lease = self.managed_worktrees.lock()
10267            .unwrap_or_else(|poisoned| poisoned.into_inner())
10268            .get(lease_id)
10269            .cloned()
10270            .ok_or_else(|| failure(
10271                NodeFailureCode::UnknownManagedWorktreeLease,
10272                "managed worktree lease does not exist",
10273            ))?;
10274        if lease.state == ManagedWorktreeLeaseState::Removed {
10275            return Ok(lease.snapshot());
10276        }
10277        if lease.has_holders() {
10278            return Err(failure(
10279                NodeFailureCode::ManagedWorktreeBusy,
10280                "managed worktree lease still has a session or durable-record holder",
10281            ));
10282        }
10283        if self
10284            .materializations
10285            .lock()
10286            .unwrap_or_else(|poisoned| poisoned.into_inner())
10287            .values()
10288            .any(|record| record.managed_lease_id() == Some(lease_id))
10289        {
10290            return Err(failure(
10291                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10292                "managed worktree cleanup is blocked by session environment ownership",
10293            ));
10294        }
10295        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
10296            let snapshot = {
10297                let mut registry = self.managed_worktrees.lock()
10298                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10299                let current = registry.get_mut(lease_id)
10300                    .expect("managed worktree lease remains present");
10301                current.state = ManagedWorktreeLeaseState::Retained;
10302                current.cleanup_failure = None;
10303                current.updated_at_unix_ms = unix_time_ms();
10304                current.snapshot()
10305            };
10306            if self.persist_state().is_err() {
10307                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10308            }
10309            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
10310            return Ok(snapshot);
10311        }
10312        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
10313        if profile.revision() != &lease.profile_revision {
10314            self.mark_managed_recovery_required(
10315                lease_id,
10316                ManagedWorktreeCleanupFailure::OwnershipConflict,
10317            );
10318            return Err(failure(
10319                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10320                "managed worktree profile revision changed",
10321            ));
10322        }
10323        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
10324            self.mark_managed_recovery_required(
10325                lease_id,
10326                ManagedWorktreeCleanupFailure::OwnershipConflict,
10327            );
10328            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
10329        })?;
10330        let source_root = self.workspace_root(&lease.source_workspace_id)?;
10331        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
10332            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
10333            managed_git_worktree_failure(error)
10334        })?;
10335        let target = listed.iter()
10336            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
10337        if let Some(target) = target {
10338            if target.is_main || target.is_bare || !exact_owned_worktree(
10339                &lease,
10340                &target.path,
10341                target.branch.as_deref(),
10342                &target.head,
10343            ) {
10344                self.mark_managed_recovery_required(
10345                    lease_id,
10346                    ManagedWorktreeCleanupFailure::OwnershipConflict,
10347                );
10348                return Err(failure(
10349                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
10350                    "Git worktree identity no longer matches the managed lease",
10351                ));
10352            }
10353            if target.prunable {
10354                self.mark_managed_cleanup_blocked(
10355                    lease_id,
10356                    ManagedWorktreeCleanupFailure::Prunable,
10357                );
10358                return Err(failure(
10359                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
10360                    "managed worktree is prunable and requires recovery",
10361                ));
10362            }
10363            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10364                let failure_kind = match error.kind {
10365                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10366                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10367                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10368                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10369                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10370                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10371                };
10372                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10373                    self.mark_managed_recovery_required(lease_id, failure_kind);
10374                } else {
10375                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10376                }
10377                return Err(managed_git_worktree_failure(error));
10378            }
10379        } else {
10380            match std::fs::symlink_metadata(&lease.target_root) {
10381                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10382                Ok(_) => {
10383                    self.mark_managed_recovery_required(
10384                        lease_id,
10385                        ManagedWorktreeCleanupFailure::OwnershipConflict,
10386                    );
10387                    return Err(failure(
10388                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
10389                        "managed target exists but is not owned by Git",
10390                    ));
10391                }
10392                Err(_) => {
10393                    self.mark_managed_cleanup_blocked(
10394                        lease_id,
10395                        ManagedWorktreeCleanupFailure::Backend,
10396                    );
10397                    return Err(failure(
10398                        NodeFailureCode::BackendOperationFailed,
10399                        "managed target identity could not be inspected",
10400                    ));
10401                }
10402            }
10403        }
10404        let registered = self.workspaces.read()
10405            .unwrap_or_else(|poisoned| poisoned.into_inner())
10406            .contains_key(&lease.workspace_id);
10407        if registered {
10408            self.unregister_workspace(&lease.workspace_id)?;
10409        }
10410        let removed = {
10411            self.managed_worktrees.lock()
10412                .unwrap_or_else(|poisoned| poisoned.into_inner())
10413                .tombstone(lease_id, unix_time_ms())
10414                .expect("managed worktree lease remains present during cleanup")
10415        };
10416        self.persist_state().map_err(persistence_failure)?;
10417        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10418        Ok(removed.snapshot())
10419    }
10420
10421    fn mark_managed_cleanup_blocked(
10422        &self,
10423        lease_id: &ManagedWorktreeLeaseId,
10424        failure_kind: ManagedWorktreeCleanupFailure,
10425    ) {
10426        let snapshot = {
10427            let mut registry = self.managed_worktrees.lock()
10428                .unwrap_or_else(|poisoned| poisoned.into_inner());
10429            let Some(lease) = registry.get_mut(lease_id) else { return };
10430            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10431            lease.cleanup_failure = Some(failure_kind);
10432            lease.updated_at_unix_ms = unix_time_ms();
10433            lease.snapshot()
10434        };
10435        let _ = self.persist_state();
10436        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10437    }
10438
10439    fn require_worktree_service(
10440        &self,
10441        source_workspace_id: &WorkspaceId,
10442    ) -> Result<(), NodeFailure> {
10443        if self.managed_worktrees.lock()
10444            .unwrap_or_else(|poisoned| poisoned.into_inner())
10445            .lease_for_workspace(source_workspace_id)
10446            .is_some()
10447        {
10448            return Err(failure(
10449                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10450                "managed worktree targets are internal-only workspaces",
10451            ));
10452        }
10453        match self
10454            .worktree_service_modes
10455            .get(source_workspace_id)
10456            .copied()
10457            .unwrap_or(WorktreeServiceMode::Manual)
10458        {
10459            WorktreeServiceMode::Manual => Ok(()),
10460            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10461                NodeFailureCode::UnsupportedSpawnCapability,
10462                "Git worktree service mode is unavailable in this node build",
10463            )),
10464        }
10465    }
10466
10467    fn managed_reservation_conflict(
10468        &self,
10469        workspace_id: Option<&WorkspaceId>,
10470        root: Option<&str>,
10471    ) -> bool {
10472        self.managed_worktrees.lock()
10473            .unwrap_or_else(|poisoned| poisoned.into_inner())
10474            .active_records()
10475            .any(|lease| {
10476                workspace_id.is_some_and(|workspace_id| {
10477                    workspace_id == &lease.source_workspace_id
10478                        || workspace_id == &lease.workspace_id
10479                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10480            })
10481    }
10482
10483    fn reject_managed_reservation(
10484        &self,
10485        workspace_id: Option<&WorkspaceId>,
10486        root: Option<&str>,
10487    ) -> Result<(), NodeFailure> {
10488        if self.managed_reservation_conflict(workspace_id, root) {
10489            Err(failure(
10490                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10491                "managed worktree ownership may only be mutated through its lease cleanup",
10492            ))
10493        } else {
10494            Ok(())
10495        }
10496    }
10497
10498    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10499        self.validate_address(address)?;
10500        self.workspace_root(&address.workspace_id)
10501    }
10502
10503    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
10504    /// `require_policy`'s own doc on those two requirements), so they keep
10505    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
10506    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
10507    /// `false` on the `require_policy` call below for them because a PTY
10508    /// resume never needs those facts, not because this method cannot look
10509    /// them up.
10510    ///
10511    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
10512    /// the SAME `binding.runtime_policy` every other requirement reads here
10513    /// (one computed policy, read by every gate). That policy is no longer
10514    /// PTY-terminal-verification-shaped for an ACP session --
10515    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
10516    /// `provider_runtime::policy_for_transport` -- so consulting it for
10517    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
10518    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
10519    /// even though a prompt is an ACP session's only interface). Pipe keeps
10520    /// reading the same PTY-probe-derived value it always has --
10521    /// `policy_for_transport` passes Pipe through unchanged this pass (see
10522    /// its own doc for why).
10523    ///
10524    /// What this method still adds on top, for ACP/Pipe only, is a
10525    /// turn-in-flight PRECONDITION ahead of that policy check: a session
10526    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
10527    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
10528    /// read off the same live `SessionSnapshot` state `require_acp_session`
10529    /// already reads for the ACP control verbs -- `TurnStarted` observed
10530    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
10531    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
10532    /// its fate in vendor-specific behaviour this node cannot observe (one
10533    /// agent may queue it, another drop it, another interleave it), so this
10534    /// refuses it outright rather than gambling on that -- a rule with no
10535    /// PTY analogue, which is why PTY does not take this extra precondition.
10536    fn require_session_runtime_policy(
10537        &self,
10538        address: &SessionAddress,
10539        requirement: ProviderRuntimeRequirement,
10540    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10541        let runtime_policy = {
10542            let bindings = self
10543                .session_bindings
10544                .lock()
10545                .unwrap_or_else(|poisoned| poisoned.into_inner());
10546            let binding = bindings
10547                .get(&address.session.instance_id)
10548                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10549            if binding.workspace_id != address.workspace_id {
10550                return Err(failure(
10551                    NodeFailureCode::SessionWorkspaceMismatch,
10552                    "session belongs to another workspace",
10553                ));
10554            }
10555            if binding.generation != address.session.generation {
10556                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10557            }
10558            binding.runtime_policy
10559        };
10560        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10561            let snapshot = self.internal_session_snapshot(address)?;
10562            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10563                && snapshot.provider.lead_activity != ProviderActivity::Idle
10564            {
10565                return Err(failure(
10566                    NodeFailureCode::TurnInFlight,
10567                    "session already has a turn in flight; wait for it to complete before sending another prompt",
10568                ));
10569            }
10570        }
10571        require_policy(runtime_policy, false, false, requirement)
10572            .map(|()| runtime_policy)
10573            .map_err(|_| failure(
10574                NodeFailureCode::UnsupportedCapability,
10575                "session runtime policy does not admit this semantic operation",
10576            ))
10577    }
10578
10579    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10580        let mut bindings = self
10581            .session_bindings
10582            .lock()
10583            .unwrap_or_else(|poisoned| poisoned.into_inner());
10584        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10585            binding.workspace_id == address.workspace_id
10586                && binding.generation == address.session.generation
10587        }) {
10588            let removed = bindings.remove(&address.session.instance_id);
10589            self.clear_terminal_frame_watermark(address);
10590            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10591            return removed;
10592        }
10593        None
10594    }
10595
10596    fn cleanup_session_owned_materialization(
10597        &self,
10598        address: &SessionAddress,
10599        binding: &SessionBinding,
10600    ) -> Result<(), NodeFailure> {
10601        let Some(id) = binding.materialization_id.as_ref() else {
10602            return Ok(());
10603        };
10604        let owner = self
10605            .materializations
10606            .lock()
10607            .unwrap_or_else(|poisoned| poisoned.into_inner())
10608            .get(id)
10609            .map(|record| record.owner().clone());
10610        match owner {
10611            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10612            Some(MaterializationOwner::Session {
10613                incarnation_id,
10614                instance_id,
10615                generation,
10616            }) if incarnation_id == self.incarnation_id
10617                && instance_id == address.session.instance_id
10618                && generation == address.session.generation =>
10619            {
10620                self.cleanup_materialization(id)
10621            }
10622            Some(MaterializationOwner::Session { .. }) => Err(failure(
10623                NodeFailureCode::BackendOperationFailed,
10624                "session environment ownership requires recovery",
10625            )),
10626        }
10627    }
10628
10629    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10630        let mut watermarks = self
10631            .terminal_frame_watermarks
10632            .lock()
10633            .unwrap_or_else(|poisoned| poisoned.into_inner());
10634        if watermarks
10635            .get(&address.session.instance_id)
10636            .is_some_and(|(current, _)| current == address)
10637        {
10638            watermarks.remove(&address.session.instance_id);
10639        }
10640    }
10641
10642    #[cfg(test)]
10643    fn address_for(
10644        &self,
10645        instance_id: AgentInstanceId,
10646        generation: SessionGeneration,
10647    ) -> Option<SessionAddress> {
10648        let binding = self.session_bindings
10649            .lock()
10650            .unwrap_or_else(|poisoned| poisoned.into_inner())
10651            .get(&instance_id)
10652            .cloned()?;
10653        (binding.generation == generation).then_some(SessionAddress {
10654            workspace_id: binding.workspace_id,
10655            session: SessionKey { instance_id, generation },
10656        })
10657    }
10658
10659    fn arm_resume(
10660        &self,
10661        address: &SessionAddress,
10662        command_id: CommandId,
10663        runtime_policy: ProviderRuntimePolicy,
10664    ) -> Result<(), NodeFailure> {
10665        let mut bindings = self
10666            .session_bindings
10667            .lock()
10668            .unwrap_or_else(|poisoned| poisoned.into_inner());
10669        let binding = bindings
10670            .get_mut(&address.session.instance_id)
10671            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10672        if binding.workspace_id != address.workspace_id {
10673            return Err(failure(
10674                NodeFailureCode::SessionWorkspaceMismatch,
10675                "session belongs to another workspace",
10676            ));
10677        }
10678        if binding.generation != address.session.generation {
10679            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10680        }
10681        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10682        Ok(())
10683    }
10684
10685    fn clear_armed_resume(&self, address: &SessionAddress) {
10686        let mut bindings = self
10687            .session_bindings
10688            .lock()
10689            .unwrap_or_else(|poisoned| poisoned.into_inner());
10690        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10691            if binding.workspace_id == address.workspace_id
10692                && binding.generation == address.session.generation
10693            {
10694                binding.pending_resume = None;
10695            }
10696        }
10697    }
10698
10699    fn publish_control(&self, event: ControlEvent) {
10700        let revokes_harness_mcp = matches!(
10701            &event.event,
10702            ControlEventKind::Exited { .. }
10703                | ControlEventKind::Failed { .. }
10704                | ControlEventKind::Removed
10705        );
10706        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10707            let mut bindings = self
10708                .session_bindings
10709                .lock()
10710                .unwrap_or_else(|poisoned| poisoned.into_inner());
10711            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10712                return;
10713            };
10714            let previous_address = SessionAddress {
10715                workspace_id: binding.workspace_id.clone(),
10716                session: SessionKey {
10717                    instance_id: event.instance_id,
10718                    generation: binding.generation,
10719                },
10720            };
10721            let mut resume_command_rejected = false;
10722            if binding.generation == event.generation {
10723                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10724                    let rejected_resume = matches!(
10725                        &event.event,
10726                        ControlEventKind::CommandRejected { .. }
10727                    ) && event.command_id == Some(pending_command_id);
10728                    let resume_failed = matches!(
10729                        &event.event,
10730                        ControlEventKind::ResumeDenied { .. }
10731                            | ControlEventKind::ResumeFailed { .. }
10732                    );
10733                    if pending_generation == binding.generation
10734                        && (rejected_resume || resume_failed)
10735                    {
10736                        resume_command_rejected = rejected_resume;
10737                        binding.pending_resume = None;
10738                    }
10739                }
10740            } else {
10741                let expected = binding.generation.0.checked_add(1);
10742                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10743                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10744                    generation == binding.generation
10745                })
10746                    || expected != Some(event.generation.0)
10747                    || !authorized
10748                {
10749                    return;
10750                }
10751                let (_, _, runtime_policy) = binding
10752                    .pending_resume
10753                    .expect("authorized resume retains its pending runtime policy");
10754                binding.generation = event.generation;
10755                binding.runtime_policy = runtime_policy;
10756                binding.pending_resume = None;
10757            }
10758            (
10759                SessionAddress {
10760                    workspace_id: binding.workspace_id.clone(),
10761                    session: SessionKey {
10762                        instance_id: event.instance_id,
10763                        generation: event.generation,
10764                    },
10765                },
10766                (previous_address.session.generation != event.generation)
10767                    .then_some(previous_address),
10768                binding.record_id.clone(),
10769                binding.runtime_policy.provider_session_identity,
10770                resume_command_rejected,
10771            )
10772        };
10773        if let Some(previous_address) = previous_address.as_ref() {
10774            self.clear_terminal_frame_watermark(previous_address);
10775        }
10776        if let Some(record_id) = record_id {
10777            self.reconcile_managed_record(
10778                &record_id,
10779                &address,
10780                &event,
10781                identity_admitted,
10782                resume_command_rejected,
10783            );
10784        }
10785        // Every state change a session's process lifecycle can reach gets a
10786        // line here: this is the single chokepoint every `ControlEventKind`
10787        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10788        // came from a fresh spawn or a resume. Per-message provider chatter
10789        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10790        // matched here -- that is exactly the per-frame noise this crate
10791        // family's level discipline forbids at INFO.
10792        match &event.event {
10793            ControlEventKind::Running { process_id } => {
10794                #[cfg(unix)]
10795                if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10796                    proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10797                }
10798                tracing::info!(
10799                    node_id = %self.node_id,
10800                    workspace_id = %address.workspace_id,
10801                    session = ?address.session,
10802                    pid = ?process_id,
10803                    "session process is running",
10804                );
10805            }
10806            ControlEventKind::Exited { exit_code, forced } => {
10807                #[cfg(unix)]
10808                if let Some(proxy) = self.signer_proxy.get().cloned() {
10809                    let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10810                    if proxy.has_session(&key) {
10811                        // Revoke at core off the event path (blocking uplink call).
10812                        std::thread::spawn(move || proxy.release(&key));
10813                    }
10814                }
10815                tracing::info!(
10816                    node_id = %self.node_id,
10817                    workspace_id = %address.workspace_id,
10818                    session = ?address.session,
10819                    exit_code = ?exit_code,
10820                    forced = forced,
10821                    "session process exited",
10822                );
10823            }
10824            ControlEventKind::Failed { message } => {
10825                tracing::warn!(
10826                    node_id = %self.node_id,
10827                    workspace_id = %address.workspace_id,
10828                    session = ?address.session,
10829                    cause = %message,
10830                    "session failed",
10831                );
10832            }
10833            ControlEventKind::CommandRejected { message } => {
10834                tracing::warn!(
10835                    node_id = %self.node_id,
10836                    workspace_id = %address.workspace_id,
10837                    session = ?address.session,
10838                    cause = %message,
10839                    "session command rejected",
10840                );
10841            }
10842            ControlEventKind::Removed => {
10843                tracing::info!(
10844                    node_id = %self.node_id,
10845                    workspace_id = %address.workspace_id,
10846                    session = ?address.session,
10847                    "session removed",
10848                );
10849            }
10850            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10851                tracing::info!(
10852                    node_id = %self.node_id,
10853                    workspace_id = %address.workspace_id,
10854                    session = ?address.session,
10855                    provider_session = ?provider_session,
10856                    "session resume authorized",
10857                );
10858            }
10859            ControlEventKind::Resumed { session: provider_session, process_id } => {
10860                tracing::info!(
10861                    node_id = %self.node_id,
10862                    workspace_id = %address.workspace_id,
10863                    session = ?address.session,
10864                    provider_session = ?provider_session,
10865                    pid = ?process_id,
10866                    "session resumed",
10867                );
10868            }
10869            ControlEventKind::ResumeDenied { reason } => {
10870                tracing::warn!(
10871                    node_id = %self.node_id,
10872                    workspace_id = %address.workspace_id,
10873                    session = ?address.session,
10874                    cause = %reason,
10875                    "session resume denied",
10876                );
10877            }
10878            ControlEventKind::ResumeFailed { message } => {
10879                tracing::warn!(
10880                    node_id = %self.node_id,
10881                    workspace_id = %address.workspace_id,
10882                    session = ?address.session,
10883                    cause = %message,
10884                    "session resume failed",
10885                );
10886            }
10887            _ => {}
10888        }
10889        self.apply_pending_acp_options(&address, &event);
10890        self.apply_acp_auto_permit(&address, &event);
10891        let stream_chunk = agent_stream_chunk(&event);
10892        self.publish(NodeEvent::Control {
10893            address: address.clone(),
10894            event,
10895        });
10896        if let Some(chunk) = stream_chunk {
10897            self.publish_agent_stream_chunk(address.clone(), chunk);
10898        }
10899        if revokes_harness_mcp {
10900            SessionLifecycleHook::on_session_ended(self, &address);
10901        }
10902    }
10903
10904    fn reconcile_managed_record(
10905        &self,
10906        record_id: &SessionRecordId,
10907        address: &SessionAddress,
10908        event: &ControlEvent,
10909        identity_admitted: bool,
10910        resume_command_rejected: bool,
10911    ) {
10912        let _transaction = self
10913            .state_transaction
10914            .lock()
10915            .unwrap_or_else(|poisoned| poisoned.into_inner());
10916        let observed_identity = match &event.event {
10917            ControlEventKind::ProviderEvent {
10918                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10919                ..
10920            } if identity_admitted => Some(identity.clone()),
10921            _ => None,
10922        };
10923        let identity_was_observed = observed_identity.is_some();
10924        let replacement_id = observed_identity
10925            .as_ref()
10926            .and_then(|_| self.allocate_record_id().ok());
10927        let mut upserts = Vec::new();
10928        let mut removals = Vec::new();
10929        let mut rebind = None;
10930        let original_records;
10931        let original_materializations = self
10932            .materializations
10933            .lock()
10934            .unwrap_or_else(|poisoned| poisoned.into_inner())
10935            .clone();
10936        {
10937            let mut records = self
10938                .session_records
10939                .lock()
10940                .unwrap_or_else(|poisoned| poisoned.into_inner());
10941            original_records = records.records.clone();
10942            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10943                return;
10944            };
10945            if let Some(identity) = observed_identity {
10946                let existing_id = records
10947                    .records
10948                    .values()
10949                    .find(|record| {
10950                        record.record_id != *record_id
10951                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10952                                session_registry::same_provider_session(
10953                                    &record.provider,
10954                                    record_identity,
10955                                    &current_snapshot.provider,
10956                                    &identity,
10957                                )
10958                            })
10959                    })
10960                    .map(|record| record.record_id.clone());
10961                if let Some(existing_id) = existing_id {
10962                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10963                        record.mode == current_snapshot.mode
10964                            && record.workspace_id == current_snapshot.workspace_id
10965                            && record.environment_profile == current_snapshot.environment_profile
10966                            && record.bundle == current_snapshot.bundle
10967                            && record.context_id == current_snapshot.context_id
10968                            && record.context == current_snapshot.context
10969                            && record
10970                                .canonical_root
10971                                .as_utf8()
10972                                .zip(current_snapshot.canonical_root.as_utf8())
10973                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
10974                    });
10975                    if !scope_matches {
10976                        if let Some(current) = records.records.get_mut(record_id) {
10977                            current.state = ManagedSessionState::Unavailable;
10978                            current.last_error =
10979                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10980                            current.updated_at_unix_ms = unix_time_ms();
10981                            upserts.push(current.clone());
10982                        }
10983                    } else {
10984                        let existing_is_active = records
10985                            .records
10986                            .get(&existing_id)
10987                            .is_some_and(|record| record.active_session.is_some());
10988                        if existing_is_active {
10989                            if let Some(current) = records.records.get_mut(record_id) {
10990                                current.state = ManagedSessionState::Unavailable;
10991                                current.last_error =
10992                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10993                                current.updated_at_unix_ms = unix_time_ms();
10994                                upserts.push(current.clone());
10995                            }
10996                        } else {
10997                            if current_snapshot.provider_session.is_some() {
10998                                if let Some(current) = records.records.get_mut(record_id) {
10999                                    current.active_session = None;
11000                                    current.state = ManagedSessionState::Dormant;
11001                                    current.updated_at_unix_ms = unix_time_ms();
11002                                    upserts.push(current.clone());
11003                                }
11004                            } else {
11005                                records.records.remove(record_id);
11006                                removals.push(record_id.clone());
11007                            }
11008                            if let Some(existing) = records.records.get_mut(&existing_id) {
11009                                existing.provider_session = Some(identity.clone());
11010                                existing.active_session = Some(address.clone());
11011                                existing.state = ManagedSessionState::Live;
11012                                existing.updated_at_unix_ms = unix_time_ms();
11013                                existing.last_error = None;
11014                                upserts.push(existing.clone());
11015                            }
11016                            rebind = Some(existing_id);
11017                        }
11018                    }
11019                } else if current_snapshot
11020                    .provider_session
11021                    .as_ref()
11022                    .is_some_and(|current| {
11023                        !session_registry::same_provider_session(
11024                            &current_snapshot.provider,
11025                            current,
11026                            &current_snapshot.provider,
11027                            &identity,
11028                        )
11029                    })
11030                {
11031                    if let Some(current) = records.records.get_mut(record_id) {
11032                        current.active_session = None;
11033                        if current.bundle.is_some() || current.context.is_some() {
11034                            let had_bundle = current.bundle.is_some();
11035                            current.provider_session = None;
11036                            current.bundle = None;
11037                            current.context_id = None;
11038                            current.context = None;
11039                            current.state = ManagedSessionState::Unavailable;
11040                            current.last_error = Some(if had_bundle {
11041                                "bundle-unavailable".to_owned()
11042                            } else {
11043                                "context-unavailable".to_owned()
11044                            });
11045                        } else {
11046                            current.state = ManagedSessionState::Dormant;
11047                        }
11048                        current.updated_at_unix_ms = unix_time_ms();
11049                        upserts.push(current.clone());
11050                    }
11051                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11052                        if let Some(current) = records.records.get_mut(record_id) {
11053                            current.state = ManagedSessionState::Unavailable;
11054                            current.last_error =
11055                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11056                            current.updated_at_unix_ms = unix_time_ms();
11057                            upserts.push(current.clone());
11058                        }
11059                    } else if let Some(new_record_id) = replacement_id {
11060                        let now = unix_time_ms();
11061                        let replacement = ManagedSessionRecord {
11062                            record_id: new_record_id.clone(),
11063                            display_name: format!(
11064                                "{} #{}",
11065                                current_snapshot.provider.as_str(),
11066                                records
11067                                    .records
11068                                    .values()
11069                                    .filter(|record| {
11070                                        record.provider == current_snapshot.provider
11071                                    })
11072                                    .count()
11073                                    .saturating_add(1),
11074                            ),
11075                            provider: current_snapshot.provider.clone(),
11076                            mode: current_snapshot.mode,
11077                            state: ManagedSessionState::Live,
11078                            workspace_id: current_snapshot.workspace_id,
11079                            canonical_root: current_snapshot.canonical_root,
11080                            provider_session: Some(identity),
11081                            active_session: Some(address.clone()),
11082                            environment_profile: current_snapshot.environment_profile,
11083                            bundle: current_snapshot.bundle,
11084                            context_id: current_snapshot.context_id,
11085                            context: current_snapshot.context,
11086                            // This is a fresh record_id representing the session under its
11087                            // newly-observed identity; it has not yet produced any clean
11088                            // exit of its own, so it starts with no exported pack (the old
11089                            // record, still addressable under its own record_id, keeps
11090                            // whatever exported_context it already had).
11091                            exported_context: None,
11092                            task_binding: current_snapshot.task_binding,
11093                            created_at_unix_ms: now,
11094                            updated_at_unix_ms: now,
11095                            last_error: None,
11096                        };
11097                        records
11098                            .records
11099                            .insert(new_record_id.clone(), replacement.clone());
11100                        upserts.push(replacement);
11101                        rebind = Some(new_record_id);
11102                    } else if let Some(current) = records.records.get_mut(record_id) {
11103                        current.state = ManagedSessionState::Unavailable;
11104                        current.last_error =
11105                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11106                        upserts.push(current.clone());
11107                    }
11108                } else if let Some(current) = records.records.get_mut(record_id) {
11109                    current.provider_session = Some(identity);
11110                    current.active_session = Some(address.clone());
11111                    current.state = ManagedSessionState::Live;
11112                    current.updated_at_unix_ms = unix_time_ms();
11113                    current.last_error = None;
11114                    upserts.push(current.clone());
11115                }
11116            } else if let Some(current) = records.records.get_mut(record_id) {
11117                if resume_command_rejected {
11118                    current.active_session = None;
11119                    current.state = detached_record_state(current);
11120                    current.updated_at_unix_ms = unix_time_ms();
11121                    current.last_error =
11122                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11123                    upserts.push(current.clone());
11124                } else {
11125                    match &event.event {
11126                    ControlEventKind::Running { .. }
11127                    | ControlEventKind::ResumeAuthorized { .. }
11128                    | ControlEventKind::Resumed { .. } => {
11129                        current.active_session = Some(address.clone());
11130                        current.state = active_record_state(
11131                            identity_admitted,
11132                            current.provider_session.is_some(),
11133                        );
11134                        current.updated_at_unix_ms = unix_time_ms();
11135                        current.last_error = None;
11136                        upserts.push(current.clone());
11137                    }
11138                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11139                        current.active_session = None;
11140                        current.state = detached_record_state(current);
11141                        current.updated_at_unix_ms = unix_time_ms();
11142                        upserts.push(current.clone());
11143                    }
11144                    ControlEventKind::Failed { message }
11145                    | ControlEventKind::ResumeFailed { message } => {
11146                        current.active_session = None;
11147                        current.state = detached_record_state(current);
11148                        current.updated_at_unix_ms = unix_time_ms();
11149                        current.last_error = Some(
11150                            session_registry::sanitized_record_error_summary(message),
11151                        );
11152                        upserts.push(current.clone());
11153                    }
11154                    ControlEventKind::ResumeDenied { reason } => {
11155                        current.active_session = None;
11156                        current.state = detached_record_state(current);
11157                        current.updated_at_unix_ms = unix_time_ms();
11158                        current.last_error = Some(
11159                            session_registry::sanitized_record_error_summary(reason),
11160                        );
11161                        upserts.push(current.clone());
11162                    }
11163                        _ => {}
11164                    }
11165                }
11166            }
11167        }
11168        let materialization_owner_record = identity_was_observed.then(|| {
11169            rebind.clone().unwrap_or_else(|| record_id.clone())
11170        }).and_then(|candidate| {
11171            self.session_records
11172                .lock()
11173                .unwrap_or_else(|poisoned| poisoned.into_inner())
11174                .records
11175                .get(&candidate)
11176                .filter(|record| {
11177                    record.provider_session.is_some()
11178                        && record.active_session.as_ref() == Some(address)
11179                })
11180                .map(|_| candidate)
11181        });
11182        if let Some(owner_record_id) = materialization_owner_record {
11183            let materialization_id = self
11184                .session_bindings
11185                .lock()
11186                .unwrap_or_else(|poisoned| poisoned.into_inner())
11187                .get(&address.session.instance_id)
11188                .and_then(|binding| binding.materialization_id.clone());
11189            if let Some(materialization_id) = materialization_id {
11190                let mut transfer_failed = false;
11191                {
11192                    let mut materializations = self
11193                        .materializations
11194                        .lock()
11195                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11196                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
11197                        match ownership.owner() {
11198                            MaterializationOwner::Session {
11199                                incarnation_id,
11200                                instance_id,
11201                                generation,
11202                            } if *incarnation_id == self.incarnation_id
11203                                && *instance_id == address.session.instance_id
11204                                && *generation == address.session.generation =>
11205                            {
11206                                transfer_failed = ownership
11207                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11208                                    .is_err();
11209                            }
11210                            MaterializationOwner::Record { record_id }
11211                                if record_id == &owner_record_id => {}
11212                            MaterializationOwner::Record { record_id } => {
11213                                let expected = record_id.clone();
11214                                transfer_failed = ownership
11215                                    .transfer_record_owner(
11216                                        &expected,
11217                                        owner_record_id.clone(),
11218                                        unix_time_ms(),
11219                                    )
11220                                    .is_err();
11221                            }
11222                            _ => transfer_failed = true,
11223                        }
11224                        if transfer_failed {
11225                            let _ = ownership.mark_recovery_required(unix_time_ms());
11226                        }
11227                    } else {
11228                        transfer_failed = true;
11229                    }
11230                }
11231                if transfer_failed {
11232                    if let Some(record) = self
11233                        .session_records
11234                        .lock()
11235                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11236                        .records
11237                        .get_mut(&owner_record_id)
11238                    {
11239                        record.state = ManagedSessionState::Unavailable;
11240                        record.last_error = Some("environment-profile-unavailable".to_owned());
11241                        record.updated_at_unix_ms = unix_time_ms();
11242                        upserts.push(record.clone());
11243                    }
11244                }
11245            }
11246        }
11247        if let Some(new_record_id) = rebind.clone() {
11248            if let Some(binding) = self
11249                .session_bindings
11250                .lock()
11251                .unwrap_or_else(|poisoned| poisoned.into_inner())
11252                .get_mut(&address.session.instance_id)
11253            {
11254                binding.record_id = Some(new_record_id);
11255            }
11256        }
11257        if upserts.is_empty() && removals.is_empty() {
11258            return;
11259        }
11260        if self.persist_state_locked().is_err() {
11261            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11262            let mut records = self
11263                .session_records
11264                .lock()
11265                .unwrap_or_else(|poisoned| poisoned.into_inner());
11266            records.records = original_records;
11267            *self
11268                .materializations
11269                .lock()
11270                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11271            if let Some(current) = records.records.get_mut(record_id) {
11272                current.state = ManagedSessionState::Unavailable;
11273                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11274                current.updated_at_unix_ms = unix_time_ms();
11275                upserts = vec![current.clone()];
11276            } else {
11277                upserts.clear();
11278            }
11279            removals.clear();
11280            drop(records);
11281            if let Some(binding) = self
11282                .session_bindings
11283                .lock()
11284                .unwrap_or_else(|poisoned| poisoned.into_inner())
11285                .get_mut(&address.session.instance_id)
11286            {
11287                binding.record_id = Some(record_id.clone());
11288            }
11289            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11290        }
11291        for record in upserts {
11292            self.publish_record(record);
11293        }
11294        for record_id in removals {
11295            self.publish(NodeEvent::SessionRecordRemoved { record_id });
11296        }
11297    }
11298
11299    fn snapshot(&self) -> NodeSnapshot {
11300        let control = self.handle.snapshot();
11301        let bindings = self
11302            .session_bindings
11303            .lock()
11304            .unwrap_or_else(|poisoned| poisoned.into_inner())
11305            .clone();
11306        let workspace_roots = self
11307            .workspaces
11308            .read()
11309            .unwrap_or_else(|poisoned| poisoned.into_inner())
11310            .clone();
11311        let environment_profiles = self
11312            .environment_profiles
11313            .read()
11314            .unwrap_or_else(|poisoned| poisoned.into_inner());
11315        let mut workspaces = workspace_roots
11316            .iter()
11317            .map(|(workspace_id, canonical_root)| {
11318                (
11319                    workspace_id.clone(),
11320                    WorkspaceSnapshot {
11321                        workspace_id: workspace_id.clone(),
11322                        canonical_root: opaque_windows_path(canonical_root.clone()),
11323                        sessions: Vec::new(),
11324                        worktree_service_mode: Some(
11325                            self.worktree_service_modes
11326                                .get(workspace_id)
11327                                .copied()
11328                                .unwrap_or(WorktreeServiceMode::Manual),
11329                        ),
11330                        managed_worktree_profiles: Some(WorktreeProfileInventory {
11331                            profiles: self
11332                                .managed_worktree_profiles
11333                                .get(workspace_id)
11334                                .into_iter()
11335                                .flat_map(BTreeMap::values)
11336                                .map(|profile| ManagedWorktreeProfileSummary {
11337                                    id: profile.profile_id().clone(),
11338                                    revision: profile.revision().clone(),
11339                                    retention: profile.retention(),
11340                                })
11341                                .collect(),
11342                        }),
11343                    },
11344                )
11345            })
11346            .collect::<BTreeMap<_, _>>();
11347        let mut agent_progress = Vec::new();
11348        for session in &control.sessions {
11349            let Some(binding) = bindings.get(&session.instance_id) else {
11350                continue;
11351            };
11352            if binding.generation != session.generation {
11353                continue;
11354            }
11355            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11356                let address = SessionAddress {
11357                    workspace_id: binding.workspace_id.clone(),
11358                    session: SessionKey {
11359                        instance_id: session.instance_id,
11360                        generation: session.generation,
11361                    },
11362                };
11363                if let Some(progress) =
11364                    agent_progress_from_provider_snapshot(address, &session.provider)
11365                {
11366                    agent_progress.push(progress);
11367                }
11368                workspace.sessions.push(session.clone());
11369            }
11370        }
11371        NodeSnapshot {
11372            node_id: self.node_id.clone(),
11373            enabled_providers: self.enabled_providers.clone(),
11374            provider_runtime_statuses: {
11375                let updates = self
11376                    .provider_runtime_status_updates
11377                    .read()
11378                    .unwrap_or_else(|poisoned| poisoned.into_inner());
11379                ProviderRuntimeStatuses::new(
11380                    self.provider_runtime_statuses
11381                        .iter()
11382                        .filter(|status| !updates.contains_key(status.provider()))
11383                        .cloned()
11384                        .chain(updates.values().cloned()),
11385                )
11386                .expect("refreshed provider runtime inventory remains bounded and unique")
11387            },
11388            workspaces: workspaces.into_values().collect(),
11389            session_records: self
11390                .session_records
11391                .lock()
11392                .unwrap_or_else(|poisoned| poisoned.into_inner())
11393                .records
11394                .values()
11395                .cloned()
11396                .collect(),
11397            managed_worktrees: self.managed_worktrees
11398                .lock()
11399                .unwrap_or_else(|poisoned| poisoned.into_inner())
11400                .snapshots(),
11401            launch_inventory: Some(LaunchInventory {
11402                spawn_profiles: Some(
11403                    self.spawn_profiles
11404                        .iter()
11405                        .filter_map(|profile| {
11406                            resolved_spawn_profile_summary(profile, &environment_profiles)
11407                        })
11408                        .collect(),
11409                ),
11410                bundles: Some(
11411                    self.bundle_catalog
11412                        .read()
11413                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11414                        .iter()
11415                        .map(NodeBundle::receipt)
11416                        .collect(),
11417                ),
11418                // Opaque station allowlist ids from empty-default catalog /
11419                // optional startup loader. Never secrets. Dig2 probe stays stub.
11420                network_allowlists: {
11421                    let catalog = self
11422                        .network_allowlist_catalog
11423                        .read()
11424                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11425                    if catalog.is_empty() {
11426                        None
11427                    } else {
11428                        Some(catalog.ids().cloned().collect())
11429                    }
11430                },
11431            }),
11432            agent_progress,
11433        }
11434    }
11435
11436    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11437        assert!(
11438            !matches!(
11439                &event,
11440                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11441            ),
11442            "terminal frame and agent stream events must use their own dedicated live channel",
11443        );
11444        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11445        let sequence = history
11446            .last_sequence
11447            .checked_add(1)
11448            .expect("node event sequence exhausted");
11449        if history.events.len() == NODE_EVENT_HISTORY_MAX {
11450            if let Some(evicted) = history.events.pop_front() {
11451                history.replay_floor_sequence = evicted
11452                    .sequence
11453                    .checked_add(1)
11454                    .expect("node replay floor sequence exhausted");
11455                history.removed_record_providers.remove(&evicted.sequence);
11456            }
11457        }
11458        match &event {
11459            NodeEvent::HarnessMcpReadCall { .. } => {
11460                panic!("transient harness MCP calls must not enter durable event history")
11461            }
11462            NodeEvent::SessionRecordUpserted { record } => {
11463                history
11464                    .record_providers
11465                    .insert(record.record_id.clone(), record.provider.clone());
11466            }
11467            NodeEvent::SessionRecordRemoved { record_id } => {
11468                if let Some(provider) = history.record_providers.remove(record_id) {
11469                    history.removed_record_providers.insert(sequence, provider);
11470                }
11471            }
11472            NodeEvent::ManagedWorktreeUpserted { .. }
11473            | NodeEvent::ManagedWorktreeRemoved { .. }
11474            | NodeEvent::Control { .. }
11475            | NodeEvent::ControllerChanged { .. }
11476            | NodeEvent::SessionRecordHistorySummarized { .. }
11477            | NodeEvent::WorkspaceAdded { .. }
11478            | NodeEvent::WorkspaceRemoved { .. }
11479            | NodeEvent::ResyncRequired { .. }
11480            | NodeEvent::AgentStream { .. }
11481            | NodeEvent::TerminalFrame { .. } => {}
11482        }
11483        let envelope = NodeEventEnvelope { sequence, event };
11484        history.events.push_back(envelope.clone());
11485        history.last_sequence = sequence;
11486        let _ = self.event_tx.send(envelope.clone());
11487        drop(history);
11488        envelope
11489    }
11490
11491    /// Publishes a transient (never durable) `NodeEvent` -- today only
11492    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
11493    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
11494    /// why this must never ride the shared `event_tx` bus). Returns whether
11495    /// the send reached at least one subscriber: `broadcast::Sender::send`
11496    /// fails in exactly one way, no active receiver at that instant, and
11497    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
11498    /// return to fail the pending call immediately rather than leave it to
11499    /// expire silently at `LOCAL_READ_TIMEOUT`/
11500    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
11501    fn publish_transient(&self, event: NodeEvent) -> bool {
11502        assert!(event.requires_harness_mcp_proxy_capability());
11503        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11504        let envelope = NodeEventEnvelope { sequence: 0, event };
11505        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11506            Ok(_) => true,
11507            Err(broadcast::error::SendError(undelivered)) => {
11508                if let Some(NodeEventEnvelope {
11509                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11510                    ..
11511                }) = undelivered.first() {
11512                    // `request.content_type` only, never a per-tool label --
11513                    // this crate does not decode `request.body` and does not
11514                    // hold `hatchery-harness-api` (Nested Control Plane
11515                    // doctrine, Law 3; see this crate's dependency on
11516                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
11517                    tracing::warn!(
11518                        call_id = call_id.as_str(),
11519                        content_type = ?request.content_type,
11520                        cause = "no-subscribers",
11521                        "harness MCP proxy event could not be published",
11522                    );
11523                }
11524                false
11525            }
11526        }
11527    }
11528
11529    fn publish_terminal_frames(&self) {
11530        let snapshot = self.handle.snapshot();
11531        let bindings = self
11532            .session_bindings
11533            .lock()
11534            .unwrap_or_else(|poisoned| poisoned.into_inner());
11535        let candidates = snapshot
11536            .sessions
11537            .iter()
11538            .filter_map(|session| {
11539                let binding = bindings.get(&session.instance_id)?;
11540                if binding.generation != session.generation {
11541                    return None;
11542                }
11543                let frame = session.terminal_frame.clone()?;
11544                crate::session_signals::feed(
11545                    binding.workspace_id.as_str(),
11546                    session.instance_id.0,
11547                    session.agent_id.as_str(),
11548                    &frame.contents,
11549                );
11550                Some((
11551                    SessionAddress {
11552                        workspace_id: binding.workspace_id.clone(),
11553                        session: SessionKey {
11554                            instance_id: session.instance_id,
11555                            generation: session.generation,
11556                        },
11557                    },
11558                    frame,
11559                ))
11560            })
11561            .collect();
11562        drop(bindings);
11563        self.publish_terminal_frame_candidates(candidates);
11564    }
11565
11566    fn publish_terminal_frame_candidates(
11567        &self,
11568        candidates: Vec<(SessionAddress, TerminalFrame)>,
11569    ) {
11570        let mut watermarks = self
11571            .terminal_frame_watermarks
11572            .lock()
11573            .unwrap_or_else(|poisoned| poisoned.into_inner());
11574        let mut events = Vec::with_capacity(candidates.len());
11575        for (address, frame) in candidates {
11576            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11577            let advanced = watermarks
11578                .get(&address.session.instance_id)
11579                .map_or(true, |(current_address, sequence)| {
11580                    current_address != &address || frame.sequence > *sequence
11581                });
11582            if !advanced {
11583                continue;
11584            }
11585            if is_first_frame {
11586                // Do NOT sessionize on first PTY output: a session is a
11587                // provider conversation id, not bytes on the wire. Drafts
11588                // leave the provisional map only when HQ attaches
11589                // (Resize / TerminalBytes). The attach-never-arrived timer
11590                // (PROVISIONAL_SPAWN_GRACE_MS) covers deploy lag; HQ's
11591                // disconnect grace starts only on a real client WS close.
11592                // This is the first PTY output this node has observed for
11593                // this session -- a one-time event per spawn, not per-frame
11594                // chatter (the watermark makes sure of that). A session that
11595                // reaches this line with no controller attached anywhere on
11596                // the node is running unobserved: exactly how processes from
11597                // repeated spawn attempts went orphaned for hours without a
11598                // single line about it.
11599                if self.controller_state().is_none() {
11600                    tracing::warn!(
11601                        node_id = %self.node_id,
11602                        workspace_id = %address.workspace_id,
11603                        session = ?address.session,
11604                        terminal_size = ?frame.size,
11605                        "session producing output with no controller attached",
11606                    );
11607                } else {
11608                    tracing::info!(
11609                        node_id = %self.node_id,
11610                        workspace_id = %address.workspace_id,
11611                        session = ?address.session,
11612                        terminal_size = ?frame.size,
11613                        "session produced its first output",
11614                    );
11615                }
11616            }
11617            watermarks.insert(
11618                address.session.instance_id,
11619                (address.clone(), frame.sequence),
11620            );
11621            if let Some((at, reported)) = self
11622                .pty_input_marks
11623                .lock()
11624                .unwrap_or_else(|poisoned| poisoned.into_inner())
11625                .get_mut(&address.session.instance_id)
11626            {
11627                // First frame after an input: PTY write -> output published.
11628                if !*reported && at.elapsed() < Duration::from_secs(5) {
11629                    *reported = true;
11630                    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");
11631                }
11632            }
11633            events.push(NodeEvent::TerminalFrame { address, frame });
11634        }
11635        drop(watermarks);
11636        if events.is_empty() {
11637            return;
11638        }
11639        let mut history = self
11640            .history
11641            .lock()
11642            .unwrap_or_else(|poisoned| poisoned.into_inner());
11643        let mut envelopes = Vec::with_capacity(events.len());
11644        for event in events {
11645            let sequence = history
11646                .last_sequence
11647                .checked_add(1)
11648                .expect("node event sequence exhausted");
11649            history.last_sequence = sequence;
11650            envelopes.push(NodeEventEnvelope { sequence, event });
11651        }
11652        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11653        drop(history);
11654    }
11655
11656    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11657    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11658    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11659    /// counter every other event kind shares, never a counter of its own --
11660    /// `queue_connection_event`/`queue_connection_event_batch` compare
11661    /// `envelope.sequence` against one `discard_events_through` watermark
11662    /// regardless of which broadcast channel it arrived on, so a
11663    /// per-channel counter would desynchronize that comparison.
11664    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11665        let mut history = self
11666            .history
11667            .lock()
11668            .unwrap_or_else(|poisoned| poisoned.into_inner());
11669        let sequence = history
11670            .last_sequence
11671            .checked_add(1)
11672            .expect("node event sequence exhausted");
11673        history.last_sequence = sequence;
11674        drop(history);
11675        // Debug, not info/warn -- this fires on every single chunk, but it
11676        // is exactly the counter the next drop-measurement pass needs: what
11677        // the node actually emitted (`source_sequence`, `kind`) versus what
11678        // a harness subscriber's own forwarder later logs having received
11679        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11680        // event"), so a gap between the two logs names itself instead of
11681        // needing a new counter added ad hoc.
11682        tracing::debug!(
11683            source_sequence = chunk.source_sequence,
11684            kind = agent_stream_chunk_kind_label(&chunk.kind),
11685            envelope_sequence = sequence,
11686            "publishing agent stream chunk",
11687        );
11688        let envelope = NodeEventEnvelope {
11689            sequence,
11690            event: NodeEvent::AgentStream { address, chunk },
11691        };
11692        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11693    }
11694
11695    fn current_sequence(&self) -> u64 {
11696        self.history
11697            .lock()
11698            .unwrap_or_else(|poisoned| poisoned.into_inner())
11699            .last_sequence
11700    }
11701
11702    fn controller_state(&self) -> Option<ControllerState> {
11703        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11704        let lease = controller.as_ref().copied()?;
11705        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11706        if remaining.is_zero() {
11707            *controller = None;
11708            return None;
11709        }
11710        Some(ControllerState {
11711            connection_id: lease.connection_id,
11712            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11713        })
11714    }
11715
11716    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11717        if role != ClientRole::Operator {
11718            tracing::warn!(
11719                node_id = %self.node_id,
11720                connection_id,
11721                "controller lease rejected: observer connections cannot acquire control",
11722            );
11723            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11724        }
11725        let lease_ms = if lease_ms == 0 {
11726            DEFAULT_CONTROLLER_LEASE_MS
11727        } else {
11728            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11729        };
11730        let now = Instant::now();
11731        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11732        if let Some(active) = controller.as_ref() {
11733            if active.connection_id != connection_id && active.expires_at > now {
11734                tracing::warn!(
11735                    node_id = %self.node_id,
11736                    connection_id,
11737                    holder_connection_id = active.connection_id,
11738                    "controller lease rejected: another operator holds it",
11739                );
11740                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11741            }
11742        }
11743        // A reconnect (this connection already holds the lease, renewing
11744        // before expiry) happens on a periodic cadence; logging it at INFO
11745        // every renewal would be exactly the heartbeat chatter this crate
11746        // family's level discipline forbids. A genuine attach -- the
11747        // controller identity actually changing -- is a state change and
11748        // gets INFO; a same-connection renewal gets DEBUG.
11749        let is_new_attach = match controller.as_ref() {
11750            Some(active) => active.connection_id != connection_id,
11751            None => true,
11752        };
11753        *controller = Some(ControllerLease {
11754            connection_id,
11755            expires_at: now + Duration::from_millis(lease_ms),
11756        });
11757        drop(controller);
11758        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11759        if is_new_attach {
11760            tracing::info!(
11761                node_id = %self.node_id,
11762                connection_id,
11763                lease_ms,
11764                "controller lease acquired",
11765            );
11766        } else {
11767            tracing::debug!(
11768                node_id = %self.node_id,
11769                connection_id,
11770                lease_ms,
11771                "controller lease renewed",
11772            );
11773        }
11774        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11775        Ok(state)
11776    }
11777
11778    fn release_controller(&self, connection_id: u64) -> bool {
11779        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11780        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11781        if released {
11782            *controller = None;
11783        }
11784        drop(controller);
11785        if released {
11786            tracing::info!(
11787                node_id = %self.node_id,
11788                connection_id,
11789                "controller lease released",
11790            );
11791            self.publish(NodeEvent::ControllerChanged { controller: None });
11792        }
11793        released
11794    }
11795
11796    /// Mark a fresh Spawn as provisional and arm the attach-never-arrived
11797    /// reaper (PROVISIONAL_SPAWN_GRACE_MS from spawn). Removed from this map
11798    /// only when HQ attaches (Resize / TerminalBytes) — that is *not* a
11799    /// provider session; it only cancels the orphan timer. Disconnect/reload
11800    /// draft kills are owned by HQ and arm only on a real client WS close.
11801    /// ACP path of launch options: once the agent advertises its config options, write the
11802    /// pending model / effort through session/set_config_option (session/set_model when the
11803    /// agent has no model option). Gaps are logged, never forced.
11804    fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11805        let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11806            return;
11807        };
11808        let id = address.session.instance_id;
11809        let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11810            return;
11811        };
11812        let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11813            .iter()
11814            .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11815            .collect();
11816        let (writes, set_model, effort_gap) =
11817            backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11818        if set_model {
11819            if let Some(model_id) = model.clone() {
11820                if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11821                    tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11822                }
11823            }
11824        }
11825        for (option_id, value_json) in writes {
11826            if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11827                tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11828            }
11829        }
11830        if effort_gap {
11831            tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11832        }
11833        tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11834    }
11835
11836    /// ACP auto-permit: switch to the provider's no-ask mode once modes are advertised, and
11837    /// answer every session/request_permission with approve.
11838    fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
11839        let id = address.session.instance_id;
11840        let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
11841        if matches!(&event.event, ControlEventKind::Exited { .. } | ControlEventKind::Failed { .. } | ControlEventKind::Removed) {
11842            crate::auto_permit::forget_acp(id.0);
11843            return;
11844        }
11845        let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
11846        if let ProviderEvent::InteractionRequested { request_id, interaction_kind, options, .. } = provider_event {
11847            tracing::debug!(instance_id = id.0, sequence = *sequence, request_id = ?request_id, kind = ?interaction_kind, options = options.len(), "acp interaction requested");
11848        }
11849        match provider_event {
11850            ProviderEvent::ModeChanged { mode_id, available } => {
11851                if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
11852                    match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
11853                        Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
11854                        Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
11855                    }
11856                }
11857            }
11858            ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
11859                match self.dispatch(ControlCommand::ResolveInteraction {
11860                    instance_id: id,
11861                    generation: address.session.generation,
11862                    interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
11863                    response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
11864                }) {
11865                    Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
11866                    Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
11867                }
11868            }
11869            // NB: ACP emits SessionEnded at the end of every *turn* (prompt stopReason), so it
11870            // must not drop the auto-permit registration; that happens on process exit below.
11871            _ => {}
11872        }
11873    }
11874
11875    fn mark_spawn_provisional(&self, address: SessionAddress) {
11876        let id = address.session.instance_id;
11877        self.provisional_spawns
11878            .lock()
11879            .unwrap_or_else(|poisoned| poisoned.into_inner())
11880            .insert(id, (address, Instant::now()));
11881        tracing::info!(
11882            node_id = %self.node_id,
11883            instance_id = id.0,
11884            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11885            "draft spawn; waiting for HQ view",
11886        );
11887    }
11888
11889    fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11890        let removed = self
11891            .provisional_spawns
11892            .lock()
11893            .unwrap_or_else(|poisoned| poisoned.into_inner())
11894            .remove(&instance_id);
11895        if removed.is_some() {
11896            tracing::info!(
11897                node_id = %self.node_id,
11898                instance_id = instance_id.0,
11899                "draft spawn claimed by HQ view",
11900            );
11901        }
11902    }
11903
11904    fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11905        let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11906        let mut map = self
11907            .provisional_spawns
11908            .lock()
11909            .unwrap_or_else(|poisoned| poisoned.into_inner());
11910        let due: Vec<AgentInstanceId> = map
11911            .iter()
11912            .filter(|(_, (_, at))| at.elapsed() >= grace)
11913            .map(|(id, _)| *id)
11914            .collect();
11915        due.into_iter()
11916            .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11917            .collect()
11918    }
11919
11920    async fn reap_provisional_spawn(&self, address: SessionAddress) {
11921        let instance_id = address.session.instance_id;
11922        tracing::warn!(
11923            node_id = %self.node_id,
11924            instance_id = instance_id.0,
11925            workspace = %address.workspace_id,
11926            reason = "attach_never_arrived",
11927            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11928            "draft spawn never viewed; stopping PTY",
11929        );
11930        let _ = self.dispatch(ControlCommand::Stop {
11931            instance_id,
11932            force: true,
11933        });
11934        let _ = self.remove_session(&address).await;
11935    }
11936
11937    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11938        if role != ClientRole::Operator {
11939            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11940        }
11941        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11942        let now = Instant::now();
11943        match controller.as_ref().copied() {
11944            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11945            Some(active) if active.expires_at <= now => {
11946                *controller = None;
11947                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11948            }
11949            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11950        }
11951    }
11952
11953    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11954        if !self
11955            .workspaces
11956            .read()
11957            .unwrap_or_else(|poisoned| poisoned.into_inner())
11958            .contains_key(&address.workspace_id)
11959        {
11960            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11961        }
11962        let binding = self
11963            .session_bindings
11964            .lock()
11965            .unwrap_or_else(|poisoned| poisoned.into_inner())
11966            .get(&address.session.instance_id)
11967            .cloned()
11968            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11969        if binding.workspace_id != address.workspace_id {
11970            return Err(failure(
11971                NodeFailureCode::SessionWorkspaceMismatch,
11972                "session belongs to another workspace",
11973            ));
11974        }
11975        if binding.generation != address.session.generation {
11976            return Err(failure(
11977                NodeFailureCode::StaleGeneration,
11978                &format!(
11979                    "stale session generation: expected {}, received {}",
11980                    binding.generation.0,
11981                    address.session.generation.0,
11982                ),
11983            ));
11984        }
11985        let snapshot = self.handle.snapshot();
11986        let Some(current) = snapshot
11987            .sessions
11988            .iter()
11989            .find(|item| item.instance_id == address.session.instance_id)
11990        else {
11991            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11992        };
11993        if current.generation != address.session.generation {
11994            return Err(failure(
11995                NodeFailureCode::StaleGeneration,
11996                &format!(
11997                    "stale session generation: expected {}, received {}",
11998                    current.generation.0,
11999                    address.session.generation.0,
12000                ),
12001            ));
12002        }
12003        Ok(current.agent_id.clone())
12004    }
12005
12006    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12007        self.dispatch_envelope(self.prepare_command(command))
12008    }
12009
12010    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12011        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12012        CommandEnvelope {
12013            id: CommandId(id),
12014            command,
12015        }
12016    }
12017
12018    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12019        let command_id = envelope.id;
12020        self.handle
12021            .dispatch(envelope)
12022            .map_err(|error| match error {
12023                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12024                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12025            })?;
12026        Ok(command_id)
12027    }
12028
12029    /// Records the kernel's own reason for asynchronously refusing
12030    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
12031    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
12032    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
12033    /// first, and if the map is still at capacity after that, the single
12034    /// oldest surviving entry is evicted -- `CommandId` is monotonically
12035    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
12036    /// is always the oldest.
12037    fn record_rejected_command(
12038        &self,
12039        command_id: CommandId,
12040        message: String,
12041        unsupported_transport: bool,
12042    ) {
12043        let now = Instant::now();
12044        let mut reasons = self.rejected_command_reasons.lock()
12045            .unwrap_or_else(|poisoned| poisoned.into_inner());
12046        reasons.retain(|_, reason| reason.expires_at > now);
12047        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12048            if let Some(&oldest) = reasons.keys().next() {
12049                reasons.remove(&oldest);
12050            }
12051        }
12052        reasons.insert(
12053            command_id,
12054            RejectedCommandReason {
12055                message,
12056                unsupported_transport,
12057                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12058            },
12059        );
12060    }
12061
12062    /// Removes and returns the kernel's reason for refusing `command_id`,
12063    /// if the drive loop has recorded one and it has not expired -- see
12064    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
12065    /// wait loop calls this instead of blindly polling out its whole
12066    /// deadline when the command it is waiting on turns out to have been
12067    /// refused rather than merely slow.
12068    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12069        let now = Instant::now();
12070        let mut reasons = self.rejected_command_reasons.lock()
12071            .unwrap_or_else(|poisoned| poisoned.into_inner());
12072        match reasons.remove(&command_id) {
12073            Some(reason) if reason.expires_at > now => Some(reason),
12074            _ => None,
12075        }
12076    }
12077
12078    async fn dispatch_bounded(
12079        &self,
12080        command: ControlCommand,
12081        timeout_duration: Duration,
12082    ) -> Result<CommandId, NodeFailure> {
12083        let deadline = Instant::now() + timeout_duration;
12084        loop {
12085            match self.dispatch(command.clone()) {
12086                Ok(command_id) => return Ok(command_id),
12087                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12088                    sleep(Duration::from_millis(2)).await;
12089                }
12090                Err(error) => return Err(error),
12091            }
12092        }
12093    }
12094
12095    fn internal_session_snapshot(
12096        &self,
12097        address: &SessionAddress,
12098    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12099        self.validate_address(address)?;
12100        self.handle
12101            .snapshot()
12102            .sessions
12103            .clone()
12104            .into_iter()
12105            .find(|session| {
12106                session.instance_id == address.session.instance_id
12107                    && session.generation == address.session.generation
12108            })
12109            .ok_or_else(|| {
12110                failure(
12111                    NodeFailureCode::UnknownSession,
12112                    "session snapshot is unavailable",
12113                )
12114            })
12115    }
12116
12117    /// Confirms `address` names a live session spawned over the ACP
12118    /// transport and returns its current snapshot -- the gate every
12119    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
12120    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
12121    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
12122    /// the session's own recorded `TransportKind` does, and that lives on
12123    /// the live snapshot, not the binding.
12124    fn require_acp_session(
12125        &self,
12126        address: &SessionAddress,
12127    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12128        let snapshot = self.internal_session_snapshot(address)?;
12129        if snapshot.transport != TransportKind::Acp {
12130            return Err(failure(
12131                NodeFailureCode::UnsupportedTransport,
12132                "this verb requires a session spawned over the ACP transport",
12133            ));
12134        }
12135        Ok(snapshot)
12136    }
12137
12138    async fn dispatch_history_bounded(
12139        &self,
12140        address: &SessionAddress,
12141        command: ControlCommand,
12142        operation: HistoryOperation,
12143    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12144        self.validate_address(address)?;
12145        let started_after = self.current_sequence();
12146        let command_id = self
12147            .dispatch_bounded(
12148                command,
12149                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12150            )
12151            .await?;
12152        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12153        let mut scan_after = started_after;
12154        let mut accepted_after = None;
12155        loop {
12156            let events = self
12157                .history
12158                .lock()
12159                .unwrap_or_else(|poisoned| poisoned.into_inner())
12160                .events
12161                .iter()
12162                .filter(|envelope| envelope.sequence > scan_after)
12163                .cloned()
12164                .collect::<Vec<_>>();
12165            for envelope in events {
12166                scan_after = scan_after.max(envelope.sequence);
12167                let NodeEvent::Control {
12168                    address: event_address,
12169                    event,
12170                } = envelope.event
12171                else {
12172                    continue;
12173                };
12174                if event_address != *address {
12175                    continue;
12176                }
12177                if event.command_id == Some(command_id) {
12178                    match &event.event {
12179                        ControlEventKind::CommandRejected { .. } => {
12180                            return Err(failure(
12181                                NodeFailureCode::InvalidRequest,
12182                                "history request was rejected",
12183                            ));
12184                        }
12185                        ControlEventKind::HistoryRequested {
12186                            operation: requested,
12187                            ..
12188                        } if requested == &operation => {
12189                            accepted_after = Some(envelope.sequence);
12190                            continue;
12191                        }
12192                        _ => {}
12193                    }
12194                }
12195                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12196                    match (&operation, &event.event) {
12197                        (
12198                            HistoryOperation::Discover { .. },
12199                            ControlEventKind::HistoryDiscovered { .. },
12200                        )
12201                        | (
12202                            HistoryOperation::Load { .. },
12203                            ControlEventKind::HistoryLoaded { .. },
12204                        ) => return self.internal_session_snapshot(address),
12205                        (_, ControlEventKind::HistoryFailed { .. }) => {
12206                            return Err(failure(
12207                                NodeFailureCode::BackendOperationFailed,
12208                                "history operation failed",
12209                            ));
12210                        }
12211                        _ => {}
12212                    }
12213                }
12214            }
12215            if Instant::now() >= deadline {
12216                return Err(failure(
12217                    NodeFailureCode::BackendBusy,
12218                    if accepted_after.is_some() {
12219                        "history operation did not settle before the bounded deadline"
12220                    } else {
12221                        "history operation was not accepted before the bounded deadline"
12222                    },
12223                ));
12224            }
12225            sleep(Duration::from_millis(2)).await;
12226        }
12227    }
12228
12229    async fn discover_history(
12230        &self,
12231        address: &SessionAddress,
12232        limit: u16,
12233    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12234        let query = HistoryQuery {
12235            working_directory: Some(self.workspace_root(&address.workspace_id)?),
12236            limit,
12237        };
12238        query
12239            .validate()
12240            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12241        let snapshot = self
12242            .dispatch_history_bounded(
12243                address,
12244                ControlCommand::DiscoverHistory {
12245                    instance_id: address.session.instance_id,
12246                    query: query.clone(),
12247                },
12248                HistoryOperation::Discover { query },
12249            )
12250            .await?;
12251        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12252            return Err(failure(
12253                NodeFailureCode::BackendOperationFailed,
12254                "history discovery settled into an invalid state",
12255            ));
12256        }
12257        Ok(snapshot.history.candidates)
12258    }
12259
12260    async fn load_history(
12261        &self,
12262        address: &SessionAddress,
12263        candidate_id: String,
12264    ) -> Result<HistorySessionRecord, NodeFailure> {
12265        validate_candidate_id(&candidate_id)
12266            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12267        let snapshot = self
12268            .dispatch_history_bounded(
12269                address,
12270                ControlCommand::LoadHistory {
12271                    instance_id: address.session.instance_id,
12272                    candidate_id: candidate_id.clone(),
12273                },
12274                HistoryOperation::Load {
12275                    candidate_id: candidate_id.clone(),
12276                },
12277            )
12278            .await?;
12279        if snapshot.history.pending.is_some()
12280            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12281        {
12282            return Err(failure(
12283                NodeFailureCode::BackendOperationFailed,
12284                "history load settled into an invalid state",
12285            ));
12286        }
12287        snapshot.history.loaded.ok_or_else(|| {
12288            failure(
12289                NodeFailureCode::BackendOperationFailed,
12290                "history load produced no bounded session",
12291            )
12292        })
12293    }
12294
12295    /// `allow_clean_detachment`: `false` for the on-demand
12296    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
12297    /// managed record must stay untouched, still live, for the whole call);
12298    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
12299    /// reacts to the very clean-exit event that detaches the record out from
12300    /// under this same call — see `session_record_export_target_matches`.
12301    async fn export_context_pack_for_session_record(
12302        &self,
12303        record_id: &SessionRecordId,
12304        session: &SessionAddress,
12305        allow_clean_detachment: bool,
12306    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12307        // C2 replaces a relayed failure's prose with a canned per-code
12308        // string (`C2NodeFailure`'s own `From`), so this path's dozen
12309        // distinct refusals are indistinguishable to anyone downstream --
12310        // and a refusal here expires a continuation, which keeps no field
12311        // for why. The prose stays on the node, where it was written.
12312        let outcome = self
12313            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12314            .await;
12315        if let Err(failure) = &outcome {
12316            tracing::warn!(
12317                record = %record_id,
12318                code = ?failure.code,
12319                reason = %failure.message,
12320                "session-record context-pack export refused",
12321            );
12322        }
12323        outcome
12324    }
12325
12326    async fn export_context_pack_for_session_record_inner(
12327        &self,
12328        record_id: &SessionRecordId,
12329        session: &SessionAddress,
12330        allow_clean_detachment: bool,
12331    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12332        // Several steps here refuse with the same BackendOperationFailed
12333        // code and no prose of their own, so which step failed is
12334        // otherwise unrecoverable -- and a refusal here expires a
12335        // continuation.
12336        let step = |name: &'static str| move |failure: NodeFailure| {
12337            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12338            failure
12339        };
12340        let record = self.record(record_id).map_err(step("record-lookup"))?;
12341        // `provider_session` is `Some` only for a provider whose resolved
12342        // runtime policy admits live identity verification (raw PTY plus a
12343        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
12344        // The PTY-sidecar-observation shape (no fleet provider today, but
12345        // capability-keyed rather than vendor-locked) hardcodes
12346        // `provider_session_identity: false` in `provider_runtime::evaluate`,
12347        // so its records go straight to `Live` with `provider_session: None`
12348        // and stay that way for the record's entire life -- that is this
12349        // shape's permanent resting state, not a race with observation, and
12350        // `discover_history`/`load_history` below do not need an identity to
12351        // do their job for it. `IdentityPending` is the actual race (an
12352        // identity-admitted provider whose observation has not landed yet):
12353        // it keeps refusing here rather than falling back, because that
12354        // provider's own history the fallback would otherwise trust is not
12355        // guaranteed to be exact without the identity it is still waiting on.
12356        let identity = match record.provider_session.clone() {
12357            Some(identity) => {
12358                if identity.key != ProviderSessionKey::SessionId {
12359                    return Err(step("provider-identity-key")(failure(
12360                        NodeFailureCode::BackendOperationFailed,
12361                        "managed session provider identity cannot bind history exactly",
12362                    )));
12363                }
12364                Some(identity)
12365            }
12366            None if record.state == ManagedSessionState::IdentityPending => {
12367                return Err(step("provider-session-identity")(failure(
12368                    NodeFailureCode::BackendOperationFailed,
12369                    "managed session has not yet observed its provider session identity",
12370                )));
12371            }
12372            None => None,
12373        };
12374        self.revalidate_session_record_context_export(
12375            &record,
12376            identity.as_ref(),
12377            session,
12378            allow_clean_detachment,
12379        ).map_err(step("revalidate-before-discovery"))?;
12380        let candidates = self
12381            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12382            .await
12383            .map_err(step("discover-history"))?;
12384        // With no verified identity to filter by, the discovered set must
12385        // still resolve to exactly one candidate: the same ambiguity refusal
12386        // an identity filter would otherwise produce is the only safety
12387        // property left available for this shape.
12388        let candidate_id = select_history_candidate(
12389            &candidates,
12390            identity.as_ref().map(|identity| identity.id.as_str()),
12391        ).map_err(step("unique-candidate"))?;
12392        let loaded = self.load_history(session, candidate_id.clone()).await
12393            .map_err(step("load-history"))?;
12394        if let Some(identity) = identity.as_ref() {
12395            if loaded.session_id != identity.id {
12396                return Err(failure(
12397                    NodeFailureCode::SessionRecordConflict,
12398                    "loaded history identity does not match the managed session record",
12399                ));
12400            }
12401        }
12402        self.revalidate_session_record_context_export(
12403            &record,
12404            identity.as_ref(),
12405            session,
12406            allow_clean_detachment,
12407        )?;
12408        self.revalidate_loaded_history(session, &candidate_id, &loaded)
12409            .map_err(step("revalidate-loaded-history"))?;
12410        let pack = self.materialize_context_pack(session).await
12411            .map_err(step("materialize-context-pack"))?;
12412        self.commit_context_pack_for_session_record(
12413            &record,
12414            identity.as_ref(),
12415            session,
12416            &candidate_id,
12417            &loaded,
12418            pack,
12419            allow_clean_detachment,
12420        )
12421    }
12422
12423    async fn export_context_pack(
12424        &self,
12425        address: &SessionAddress,
12426    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12427        let pack = self.materialize_context_pack(address).await?;
12428        self.commit_context_pack(pack)
12429    }
12430
12431    async fn materialize_context_pack(
12432        &self,
12433        address: &SessionAddress,
12434    ) -> Result<NodeContextPack, NodeFailure> {
12435        let snapshot = self.internal_session_snapshot(address)?;
12436        if !context_pack_source_status_is_usable(&snapshot.status) {
12437            return Err(failure(
12438                NodeFailureCode::InvalidRequest,
12439                "context export requires a running or successfully exited source session",
12440            ));
12441        }
12442        if snapshot.history.pending.is_some()
12443            || snapshot.history.loaded_candidate_id.is_none()
12444        {
12445            return Err(failure(
12446                NodeFailureCode::InvalidRequest,
12447                "context export requires settled loaded history",
12448            ));
12449        }
12450        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12451            failure(
12452                NodeFailureCode::InvalidRequest,
12453                "context export requires settled loaded history",
12454            )
12455        })?;
12456        let source_provider = snapshot.agent_id.clone();
12457        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12458        let repository = self
12459            .context_pack_repository(&address.workspace_id)
12460            .await?;
12461        let current = self.internal_session_snapshot(address)?;
12462        if !context_pack_source_status_is_usable(&current.status)
12463            || current.agent_id != source_provider
12464            || current.history.pending.is_some()
12465            || current.history.loaded_candidate_id != loaded_candidate_id
12466            || current.history.loaded.as_ref() != Some(&loaded)
12467        {
12468            return Err(failure(
12469                NodeFailureCode::ContextPackMaterializationFailed,
12470                "context source changed during bounded repository capture",
12471            ));
12472        }
12473        NodeContextPack::export_with_repository(
12474            ContextPackLineageReceipt {
12475                source_node_id: self.node_id.clone(),
12476                source_session: address.clone(),
12477                source_provider,
12478            },
12479            &loaded,
12480            Some(repository),
12481        )
12482        .map_err(|_| {
12483            failure(
12484                NodeFailureCode::ContextPackMaterializationFailed,
12485                "bounded context pack export failed",
12486            )
12487        })
12488    }
12489
12490    /// Write-through to the durable [`ContextPackStore`], if one is
12491    /// configured (`state_path` is `Some`). A node with no durable state
12492    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
12493    /// open at all and this is a no-op, matching `persist_state`'s own
12494    /// "durability optional, degrade gracefully when unconfigured" idiom.
12495    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12496        let mut store = self
12497            .context_pack_store
12498            .lock()
12499            .unwrap_or_else(|poisoned| poisoned.into_inner());
12500        let Some(store) = store.as_mut() else {
12501            return Ok(());
12502        };
12503        store.commit(pack).map_err(|_| {
12504            failure(
12505                NodeFailureCode::ContextPackMaterializationFailed,
12506                "durable context pack commit failed",
12507            )
12508        })
12509    }
12510
12511    fn commit_context_pack(
12512        &self,
12513        pack: NodeContextPack,
12514    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12515        self.commit_context_pack_durably(&pack)?;
12516        self.context_catalog
12517            .write()
12518            .unwrap_or_else(|poisoned| poisoned.into_inner())
12519            .insert(pack)
12520            .map_err(|_| {
12521                failure(
12522                    NodeFailureCode::ContextPackMaterializationFailed,
12523                    "context pack catalog is full",
12524                )
12525            })
12526    }
12527
12528    fn commit_context_pack_for_session_record(
12529        &self,
12530        expected: &ManagedSessionRecord,
12531        identity: Option<&ProviderSessionIdentity>,
12532        session: &SessionAddress,
12533        candidate_id: &str,
12534        loaded: &HistorySessionRecord,
12535        pack: NodeContextPack,
12536        allow_clean_detachment: bool,
12537    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12538        let current_root = self.workspace_root(&expected.workspace_id)?;
12539        let _transaction = self
12540            .state_transaction
12541            .lock()
12542            .unwrap_or_else(|poisoned| poisoned.into_inner());
12543        let records = self
12544            .session_records
12545            .lock()
12546            .unwrap_or_else(|poisoned| poisoned.into_inner());
12547        let current = records.records.get(&expected.record_id).ok_or_else(|| {
12548            failure(
12549                NodeFailureCode::SessionRecordConflict,
12550                "managed session record disappeared during context export",
12551            )
12552        })?;
12553        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12554            return Err(failure(
12555                NodeFailureCode::SessionRecordConflict,
12556                "managed session record changed during context export",
12557            ));
12558        }
12559        let bindings = self
12560            .session_bindings
12561            .lock()
12562            .unwrap_or_else(|poisoned| poisoned.into_inner());
12563        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12564            failure(
12565                NodeFailureCode::SessionRecordConflict,
12566                "managed session runtime binding disappeared during context export",
12567            )
12568        })?;
12569        let runtime = self
12570            .handle
12571            .snapshot()
12572            .sessions
12573            .iter()
12574            .find(|snapshot| {
12575                snapshot.instance_id == session.session.instance_id
12576                    && snapshot.generation == session.session.generation
12577            })
12578            .cloned()
12579            .ok_or_else(|| {
12580                failure(
12581                    NodeFailureCode::SessionRecordConflict,
12582                    "managed session runtime disappeared during context export",
12583                )
12584            })?;
12585        let receipt = pack.receipt();
12586        if !session_record_context_export_binding_is_exact(
12587            expected,
12588            current,
12589            identity,
12590            session,
12591            binding,
12592            &runtime.agent_id,
12593            &current_root,
12594            allow_clean_detachment,
12595        ) || !session_record_context_export_source_is_usable(
12596            current,
12597            &runtime.status,
12598            allow_clean_detachment,
12599        ) || runtime.history.pending.is_some()
12600            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12601            || runtime.history.loaded.as_ref() != Some(loaded)
12602            || receipt.lineage.source_node_id != self.node_id
12603            || receipt.lineage.source_session != *session
12604            || receipt.lineage.source_provider != current.provider
12605            || receipt.source_message_count != loaded.message_count
12606        {
12607            return Err(failure(
12608                NodeFailureCode::SessionRecordConflict,
12609                "managed session source changed before context catalog commit",
12610            ));
12611        }
12612        self.commit_context_pack_durably(&pack)?;
12613        self.context_catalog
12614            .write()
12615            .unwrap_or_else(|poisoned| poisoned.into_inner())
12616            .insert(pack)
12617            .map_err(|_| {
12618                failure(
12619                    NodeFailureCode::ContextPackMaterializationFailed,
12620                    "context pack catalog is full",
12621                )
12622            })
12623    }
12624
12625    async fn context_pack_repository(
12626        &self,
12627        workspace_id: &WorkspaceId,
12628    ) -> Result<ContextPackRepository, NodeFailure> {
12629        let first = self
12630            .context_pack_repository_observation(workspace_id)
12631            .await?;
12632        let second = self
12633            .context_pack_repository_observation(workspace_id)
12634            .await?;
12635        stable_context_pack_repository(first, second)
12636    }
12637
12638    async fn context_pack_repository_observation(
12639        &self,
12640        workspace_id: &WorkspaceId,
12641    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12642        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12643        let head = if git.is_repository {
12644            let root = self.workspace_root(workspace_id)?;
12645            match resolve_base_commit_with_timeout(
12646                &root,
12647                "HEAD",
12648                GIT_COMMAND_TIMEOUT_MS,
12649            )
12650            .await
12651            {
12652                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12653                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12654                    ContextPackRepositoryHead::Unborn
12655                }
12656                Err(_) => {
12657                    return Err(failure(
12658                        NodeFailureCode::ContextPackMaterializationFailed,
12659                        "context pack HEAD observation failed",
12660                    ));
12661                }
12662            }
12663        } else {
12664            ContextPackRepositoryHead::NotRepository
12665        };
12666        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12667        for selected_path in CONTEXT_PACK_SELECTED_FILES {
12668            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12669                failure(
12670                    NodeFailureCode::InvalidRepositoryPath,
12671                    "context pack selected file path is invalid",
12672                )
12673            })?;
12674            match self.read_workspace_file(workspace_id.clone(), path).await {
12675                Ok(WorkspaceFileRead {
12676                    content: WorkspaceFileContent::Utf8 { text, byte_len },
12677                    ..
12678                }) => files.push(ContextPackRepositoryFileSource::utf8(
12679                    selected_path,
12680                    text,
12681                    byte_len,
12682                )),
12683                Ok(WorkspaceFileRead {
12684                    content: WorkspaceFileContent::NonUtf8 { byte_len },
12685                    ..
12686                }) => files.push(ContextPackRepositoryFileSource::skipped(
12687                    selected_path,
12688                    ContextPackSelectedFileSkipReason::NonUtf8,
12689                    Some(byte_len),
12690                )),
12691                Ok(WorkspaceFileRead {
12692                    content: WorkspaceFileContent::TooLarge { .. },
12693                    ..
12694                }) => files.push(ContextPackRepositoryFileSource::skipped(
12695                    selected_path,
12696                    ContextPackSelectedFileSkipReason::TooLarge,
12697                    None,
12698                )),
12699                Err(error) => {
12700                    let reason = if matches!(
12701                        error.code,
12702                        NodeFailureCode::InvalidRepositoryPath
12703                            | NodeFailureCode::RepositoryFileNotRegular
12704                            | NodeFailureCode::RepositoryPathUnsafe
12705                    ) {
12706                        ContextPackSelectedFileSkipReason::Unsafe
12707                    } else {
12708                        ContextPackSelectedFileSkipReason::Unavailable
12709                    };
12710                    files.push(ContextPackRepositoryFileSource::skipped(
12711                        selected_path,
12712                        reason,
12713                        None,
12714                    ));
12715                }
12716            }
12717        }
12718        Ok(ContextPackRepositoryObservation { head, git, files })
12719    }
12720
12721    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12722        let referenced_by_binding = self
12723            .session_bindings
12724            .lock()
12725            .unwrap_or_else(|poisoned| poisoned.into_inner())
12726            .values()
12727            .any(|binding| {
12728                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12729            });
12730        let referenced_by_record = self
12731            .session_records
12732            .lock()
12733            .unwrap_or_else(|poisoned| poisoned.into_inner())
12734            .records
12735            .values()
12736            .any(|record| record.context_id.as_ref() == Some(context_id));
12737        let referenced_by_materialization = self
12738            .materializations
12739            .lock()
12740            .unwrap_or_else(|poisoned| poisoned.into_inner())
12741            .values()
12742            .any(|ownership| {
12743                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12744            });
12745        if referenced_by_binding
12746            || referenced_by_record
12747            || referenced_by_materialization
12748        {
12749            return Err(failure(
12750                NodeFailureCode::ContextPackBusy,
12751                "context pack is still referenced",
12752            ));
12753        }
12754        let mut catalog = self.context_catalog
12755            .write()
12756            .unwrap_or_else(|poisoned| poisoned.into_inner());
12757        if catalog.get(context_id).is_none() {
12758            return Err(failure(
12759                NodeFailureCode::UnknownContextPack,
12760                "context pack is unavailable",
12761            ));
12762        }
12763        self.spawn_idempotency
12764            .lock()
12765            .unwrap_or_else(|poisoned| poisoned.into_inner())
12766            .entries
12767            .retain(|_, entry| !entry.value.references_context(context_id));
12768        let removed = catalog.remove(context_id);
12769        debug_assert!(removed.is_some(), "validated context pack remains present");
12770        Ok(())
12771    }
12772
12773    async fn dispatch_input_bounded(
12774        &self,
12775        address: &SessionAddress,
12776        action: InputAction,
12777    ) -> Result<(), NodeFailure> {
12778        let started_after = self.current_sequence();
12779        self.pty_input_marks
12780            .lock()
12781            .unwrap_or_else(|poisoned| poisoned.into_inner())
12782            .insert(address.session.instance_id, (Instant::now(), false));
12783        self.pty_input_wake.notify_one();
12784        let command_id = self
12785            .dispatch_bounded(
12786                ControlCommand::SendInput {
12787                    instance_id: address.session.instance_id,
12788                    action,
12789                },
12790                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12791            )
12792            .await?;
12793        // A semantic prompt may spend the provider's full readiness budget before
12794        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12795        // but strictly beyond every readiness policy in this node's catalog.
12796        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12797        let mut scan_after = started_after;
12798        let mut accepted_after = None;
12799        loop {
12800            let events = self
12801                .history
12802                .lock()
12803                .unwrap_or_else(|poisoned| poisoned.into_inner())
12804                .events
12805                .iter()
12806                .filter(|envelope| envelope.sequence > scan_after)
12807                .cloned()
12808                .collect::<Vec<_>>();
12809            for envelope in events {
12810                scan_after = scan_after.max(envelope.sequence);
12811                let NodeEvent::Control {
12812                    address: event_address,
12813                    event,
12814                } = envelope.event
12815                else {
12816                    continue;
12817                };
12818                if event_address != *address {
12819                    continue;
12820                }
12821                if event.command_id == Some(command_id) {
12822                    match &event.event {
12823                        ControlEventKind::CommandRejected { message } => {
12824                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12825                        }
12826                        ControlEventKind::InputRequested { .. } => {
12827                            accepted_after = Some(envelope.sequence);
12828                            continue;
12829                        }
12830                        _ => {}
12831                    }
12832                }
12833                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12834                    match &event.event {
12835                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12836                        ControlEventKind::InputFailed { message, .. } => {
12837                            return Err(failure(
12838                                NodeFailureCode::BackendOperationFailed,
12839                                message,
12840                            ));
12841                        }
12842                        _ => {}
12843                    }
12844                }
12845            }
12846            if Instant::now() >= deadline {
12847                return Err(failure(
12848                    NodeFailureCode::BackendBusy,
12849                    if accepted_after.is_some() {
12850                        "input was accepted but did not settle before the bounded deadline"
12851                    } else {
12852                        "input was not accepted before the bounded deadline"
12853                    },
12854                ));
12855            }
12856            sleep(Duration::from_millis(2)).await;
12857        }
12858    }
12859
12860    async fn dispatch_resize_bounded(
12861        &self,
12862        address: &SessionAddress,
12863        size: gate4agent_types::TerminalSize,
12864    ) -> Result<(), NodeFailure> {
12865        let started_after = self.current_sequence();
12866        let command_id = self
12867            .dispatch_bounded(
12868                ControlCommand::Resize {
12869                    instance_id: address.session.instance_id,
12870                    size,
12871                },
12872                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12873            )
12874            .await?;
12875        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12876        let mut scan_after = started_after;
12877        let mut accepted_after = None;
12878        loop {
12879            let events = self
12880                .history
12881                .lock()
12882                .unwrap_or_else(|poisoned| poisoned.into_inner())
12883                .events
12884                .iter()
12885                .filter(|envelope| envelope.sequence > scan_after)
12886                .cloned()
12887                .collect::<Vec<_>>();
12888            for envelope in events {
12889                scan_after = scan_after.max(envelope.sequence);
12890                let NodeEvent::Control {
12891                    address: event_address,
12892                    event,
12893                } = envelope.event
12894                else {
12895                    continue;
12896                };
12897                if event_address != *address {
12898                    continue;
12899                }
12900                if event.command_id == Some(command_id) {
12901                    match &event.event {
12902                        ControlEventKind::CommandRejected { message } => {
12903                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12904                        }
12905                        ControlEventKind::ResizeRequested {
12906                            size: requested_size,
12907                            ..
12908                        } if *requested_size == size => {
12909                            accepted_after = Some(envelope.sequence);
12910                            continue;
12911                        }
12912                        _ => {}
12913                    }
12914                }
12915                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12916                    match &event.event {
12917                        ControlEventKind::Resized { size: settled_size }
12918                            if *settled_size == size =>
12919                        {
12920                            return Ok(());
12921                        }
12922                        ControlEventKind::ResizeFailed { message } => {
12923                            return Err(failure(
12924                                NodeFailureCode::BackendOperationFailed,
12925                                message,
12926                            ));
12927                        }
12928                        _ => {}
12929                    }
12930                }
12931            }
12932            if Instant::now() >= deadline {
12933                return Err(failure(
12934                    NodeFailureCode::BackendBusy,
12935                    if accepted_after.is_some() {
12936                        "resize was accepted but did not settle before the bounded deadline"
12937                    } else {
12938                        "resize was not accepted before the bounded deadline"
12939                    },
12940                ));
12941            }
12942            sleep(Duration::from_millis(2)).await;
12943        }
12944    }
12945
12946    async fn spawn_session(
12947        &self,
12948        workspace_id: WorkspaceId,
12949        provider: AgentId,
12950        mode: SessionMode,
12951        terminal_size: gate4agent_types::TerminalSize,
12952        initial_prompt: Option<String>,
12953    ) -> Result<SessionAddress, NodeFailure> {
12954        if self.managed_worktrees.lock()
12955            .unwrap_or_else(|poisoned| poisoned.into_inner())
12956            .lease_for_workspace(&workspace_id)
12957            .is_some()
12958        {
12959            return Err(failure(
12960                NodeFailureCode::ManagedWorktreeOwnershipConflict,
12961                "managed worktree targets may only be spawned through their lease request",
12962            ));
12963        }
12964        self
12965            .spawn_session_with_deadline(
12966                workspace_id,
12967                provider.clone(),
12968                mode,
12969                terminal_size,
12970                initial_prompt,
12971                None,
12972                None,
12973                None,
12974                None,
12975                None,
12976                SpawnRecordPolicy::ProviderIdentityOnly,
12977                None,
12978                &[],
12979                None,
12980                // `NodeRequest::Spawn` (this method's only caller) carries no
12981                // approval-level selection of its own -- see
12982                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
12983                // the one operator-wire path that can choose something other
12984                // than the axis default. Auto-permit may lower it where the agent
12985                // cannot offer its full-auto mode (claude ACP under root).
12986                crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
12987                None,
12988                None,
12989            )
12990            .await
12991            .map(|(session, _runtime_policy)| session)
12992    }
12993
12994    async fn spawn_session_with_deadline(
12995        &self,
12996        workspace_id: WorkspaceId,
12997        provider: AgentId,
12998        mode: SessionMode,
12999        terminal_size: gate4agent_types::TerminalSize,
13000        initial_prompt: Option<String>,
13001        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13002        bundle: Option<ResolvedBundleReceipt>,
13003        context: Option<ResolvedContextPackReceipt>,
13004        managed_authority: Option<ManagedWorktreeLeaseId>,
13005        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13006        record_policy: SpawnRecordPolicy,
13007        deadline: Option<Instant>,
13008        required_capabilities: &[ProviderRuntimeCapability],
13009        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13010        approval_level: ApprovalLevel,
13011        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13012        // Opaque dig2browser station profile id (ids only). Feature
13013        // `dig2-station-probe`: after probe at resolve, spawn takes an
13014        // exclusive node-local lease; cleared on session end. Never
13015        // ImportSession / cookies on C2. Feature off: ignored (stub echo
13016        // already happened on the receipt path).
13017        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13018    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13019        let reserved_lease = self.managed_worktrees.lock()
13020            .unwrap_or_else(|poisoned| poisoned.into_inner())
13021            .lease_for_workspace(&workspace_id);
13022        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13023            (Some(expected), Some(provided)) if expected == provided => {}
13024            (None, None) => {}
13025            _ => {
13026                return Err(failure(
13027                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
13028                    "spawn lacks exact managed worktree lease authority",
13029                ));
13030            }
13031        }
13032        // Refuse concurrent distinct environment profiles (incl. ProviderHome
13033        // bindings) on the same workspace cwd. Parallel path: managed worktree
13034        // spawn so each profile gets its own workspace_id.
13035        self.ensure_workspace_environment_profile_compatible(
13036            &workspace_id,
13037            environment_profile.as_ref(),
13038        )?;
13039        let runtime_requirement = match (mode, initial_prompt.is_some()) {
13040            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13041            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13042            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13043            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13044        };
13045        // This session's own transport -- computed once, here, from `mode`,
13046        // and reused both to admit spawn (below) and, further down, to
13047        // compute the final `runtime_policy` this session actually operates
13048        // under (`provider_runtime::policy_for_transport`). Registration
13049        // reuses this exact value rather than recomputing it a second time.
13050        let transport = match mode {
13051            SessionMode::Pty => TransportKind::Pty,
13052            SessionMode::Inline => TransportKind::Pipe,
13053            SessionMode::Acp => TransportKind::Acp,
13054        };
13055        let pipe_transport = self.provider_supports_pipe_transport(&provider);
13056        let acp_transport = self.provider_supports_acp_transport(&provider);
13057        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13058            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13059                NodeFailureCode::UnsupportedSpawnCapability,
13060                "pre-admitted provider runtime no longer satisfies spawn requirements",
13061            ))?;
13062            runtime_policy
13063        } else if let Some(deadline) = deadline {
13064            let remaining = spawn_deadline_remaining(deadline)?;
13065            timeout(
13066                remaining,
13067                self.admit_provider_runtime(&provider, runtime_requirement),
13068            )
13069            .await
13070            .map_err(|_| {
13071                failure(
13072                    NodeFailureCode::SpawnDeadlineExceeded,
13073                    "provider admission exceeded the spawn deadline",
13074                )
13075            })??
13076        } else {
13077            self.admit_provider_runtime(&provider, runtime_requirement).await?
13078        };
13079        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13080        // The single point where this session's `runtime_policy` becomes
13081        // transport-correct: `admit_provider_runtime` (and the pre-admitted
13082        // branch above) only ever compute a PTY-terminal-verification-shaped
13083        // value, sound outright only for `TransportKind::Pty` (Pipe still
13084        // relies on that same value downstream -- see `policy_for_transport`'s
13085        // own doc for why). `policy_for_transport` is the explicit,
13086        // exhaustive match Item 1 asks for -- this is the ONE place its
13087        // output becomes the session's `runtime_policy`, so every later
13088        // reader (`ControlCommand::Start`, `session_bindings`, and every
13089        // engine gate that reads `state.runtime_policy`) sees the same,
13090        // correctly-computed value.
13091        let runtime_policy =
13092            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13093        if required_capabilities
13094            .iter()
13095            .any(|capability| !runtime_policy.admits(*capability))
13096        {
13097            return Err(failure(
13098                NodeFailureCode::UnsupportedSpawnCapability,
13099                "provider runtime does not admit a required spawn capability",
13100            ));
13101        }
13102        if let Some(deadline) = deadline {
13103            spawn_deadline_remaining(deadline)?;
13104        }
13105        self.ensure_binding_capacity()?;
13106        let mut working_directory = self.workspace_root(&workspace_id)?;
13107        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13108        let session = SessionKey {
13109            instance_id,
13110            generation: SessionGeneration(1),
13111        };
13112        let address = SessionAddress {
13113            workspace_id: workspace_id.clone(),
13114            session,
13115        };
13116        // This is the request-accepted checkpoint: admission (managed
13117        // worktree authority, runtime capability, dispatch deadline) already
13118        // passed and a concrete session identity is minted. It is a distinct
13119        // state from the process actually running (`ControlEventKind::Running`,
13120        // logged in `publish_control`) or the terminal outcome (`Exited`/
13121        // `Failed`, logged there too) -- this line exists so that gap is no
13122        // longer silent.
13123        tracing::info!(
13124            node_id = %self.node_id,
13125            workspace_id = %workspace_id,
13126            provider = %provider,
13127            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13128            session = ?address.session,
13129            "session spawn request accepted",
13130        );
13131        let mut prepared_spawn = SpawnPreparer::prepare(
13132            self,
13133            SpawnPrepareRequest {
13134                instance_id,
13135                address: &address,
13136                provider: &provider,
13137                mode,
13138                environment_profile: environment_profile.as_ref(),
13139                bundle: bundle.as_ref(),
13140                context: context.as_ref(),
13141                managed_authority: managed_authority.as_ref(),
13142                harness_mcp,
13143                approval_level,
13144                network_allowlist: network_allowlist.as_ref(),
13145                browser_profile_id,
13146            },
13147        )?;
13148        let materialization_id = prepared_spawn.materialization_id.take();
13149        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13150        let record_id = self.bind_spawn_session_with_materialization(
13151            &address,
13152            provider.clone(),
13153            mode,
13154            runtime_policy,
13155            record_policy,
13156            environment_profile,
13157            bundle,
13158            context,
13159            materialization_id,
13160        )?;
13161        prepared_spawn.retain_session_guards();
13162        // `transport` was already computed above, alongside this session's
13163        // `runtime_policy`; reused here for `Register` rather than derived a
13164        // second time.
13165        let session_provider = provider.clone();
13166        let agent_id = provider;
13167        let register_command_id = match self.dispatch_bounded(
13168            ControlCommand::Register {
13169                instance_id,
13170                agent_id,
13171                transport,
13172            },
13173            dispatch_timeout,
13174        )
13175        .await {
13176            Ok(command_id) => command_id,
13177            Err(error) => {
13178                if let Some(binding) = self.remove_binding(&address) {
13179                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13180                }
13181                if let Some(record_id) = record_id.as_ref() {
13182                    self.discard_record(record_id)?;
13183                }
13184                return Err(spawn_dispatch_error(error, deadline));
13185            }
13186        };
13187        let start_timeout = match spawn_dispatch_timeout(deadline) {
13188            Ok(timeout) => timeout,
13189            Err(error) => {
13190                let recovery = self
13191                    .rollback_spawn(
13192                        &address,
13193                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13194                    )
13195                    .await;
13196                return match recovery {
13197                    Ok(()) => {
13198                        if let Some(record_id) = record_id.as_ref() {
13199                            self.discard_record(record_id)?;
13200                        }
13201                        Err(error)
13202                    }
13203                    Err(recovery_error) => {
13204                        if let Some(record_id) = record_id.as_ref() {
13205                            self.mark_record_error(
13206                                record_id,
13207                                "spawn deadline elapsed and registration recovery failed",
13208                            )?;
13209                        }
13210                        Err(recovery_error)
13211                    }
13212                };
13213            }
13214        };
13215        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13216            if let Err(recovery_error) = self
13217                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13218                .await
13219            {
13220                return Err(recovery_error);
13221            }
13222            if let Some(record_id) = record_id.as_ref() {
13223                self.discard_record(record_id)?;
13224            }
13225            return Err(start_error);
13226        }
13227        // A session with its own m4a account runs in its own folder inside the workspace.
13228        if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13229            working_directory = dir.to_string_lossy().into_owned();
13230        }
13231        let start_result = self
13232            .dispatch_bounded(
13233                ControlCommand::Start {
13234                    instance_id,
13235                    runtime_policy,
13236                    request: StartRequest {
13237                        working_directory,
13238                        terminal_size,
13239                        initial_prompt,
13240                        session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13241                        approval_level,
13242                    },
13243                },
13244                start_timeout,
13245            )
13246            .await;
13247        let start_command_id = match start_result {
13248            Ok(command_id) => command_id,
13249            Err(start_error) => {
13250                let start_error = spawn_dispatch_error(start_error, deadline);
13251                let recovery = self
13252                    .rollback_spawn(
13253                        &address,
13254                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13255                    )
13256                    .await;
13257                return match recovery {
13258                    Ok(()) => {
13259                        if let Some(record_id) = record_id.as_ref() {
13260                            self.discard_record(record_id)?;
13261                        }
13262                        Err(start_error)
13263                    }
13264                    Err(recovery_error) => {
13265                        let diagnostic = format!(
13266                            "start failed and registration recovery failed: {}",
13267                            recovery_error.message,
13268                        );
13269                        if let Some(record_id) = record_id.as_ref() {
13270                            self.mark_record_error(record_id, &diagnostic)?;
13271                        }
13272                        Err(failure(recovery_error.code, &diagnostic))
13273                    }
13274                };
13275            }
13276        };
13277
13278        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13279        loop {
13280            // `Register`/`Start` only fail synchronously for a port-level
13281            // ingress problem (see `dispatch_bounded`); the kernel refusing
13282            // either command's content -- an unsupported transport, an
13283            // unknown agent -- lands one drive-loop tick later, and no
13284            // session ever appears in `self.handle.snapshot()` for it to
13285            // notice below. Check for that named reason before falling
13286            // through to the blind presence poll, so a rejected spawn
13287            // reports why instead of just timing out.
13288            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13289                .or_else(|| self.take_rejected_command_reason(start_command_id))
13290            {
13291                self.rollback_spawn(
13292                    &address,
13293                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13294                ).await.map_err(|error| {
13295                        failure(
13296                            error.code,
13297                            &format!(
13298                                "spawn was rejected ({}) and registration recovery failed: {}",
13299                                reason.message, error.message
13300                            ),
13301                        )
13302                    })?;
13303                if let Some(record_id) = record_id.as_ref() {
13304                    self.discard_record(record_id)?;
13305                }
13306                let code = if reason.unsupported_transport {
13307                    NodeFailureCode::UnsupportedTransport
13308                } else {
13309                    NodeFailureCode::BackendOperationFailed
13310                };
13311                return Err(failure(code, &reason.message));
13312            }
13313            if let Some(current) = self
13314                .handle
13315                .snapshot()
13316                .sessions
13317                .iter()
13318                .find(|current| current.instance_id == instance_id)
13319            {
13320                if current.generation != session.generation {
13321                    let divergence = failure(
13322                        NodeFailureCode::StaleGeneration,
13323                        "spawn generation diverged",
13324                    );
13325                    return match self
13326                        .rollback_spawn(
13327                            &address,
13328                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13329                        )
13330                        .await
13331                    {
13332                        Ok(()) => {
13333                            if let Some(record_id) = record_id.as_ref() {
13334                                self.discard_record(record_id)?;
13335                            }
13336                            Err(divergence)
13337                        }
13338                        Err(recovery_error) => {
13339                            if let Some(record_id) = record_id.as_ref() {
13340                                self.mark_record_error(
13341                                    record_id,
13342                                    "spawn generation diverged and recovery failed",
13343                                )?;
13344                            }
13345                            Err(recovery_error)
13346                        }
13347                    };
13348                }
13349                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13350                    prepared_spawn.retain_launch_guards();
13351                    return Ok((address, runtime_policy));
13352                }
13353            }
13354            if Instant::now() >= commit_deadline {
13355                self.rollback_spawn(
13356                    &address,
13357                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13358                ).await.map_err(|error| {
13359                        failure(
13360                            error.code,
13361                            &format!(
13362                                "start did not commit and registration recovery failed: {}",
13363                                error.message
13364                            ),
13365                        )
13366                    })?;
13367                if let Some(record_id) = record_id.as_ref() {
13368                    self.discard_record(record_id)?;
13369                }
13370                return Err(failure(
13371                    if deadline.is_some() {
13372                        NodeFailureCode::SpawnDeadlineExceeded
13373                    } else {
13374                        NodeFailureCode::BackendBusy
13375                    },
13376                    "start did not commit before the bounded deadline; registration was removed",
13377                ));
13378            }
13379            sleep(Duration::from_millis(2)).await;
13380        }
13381    }
13382
13383    fn effective_spawn_runtime_policy(
13384        &self,
13385        runtime_policy: ProviderRuntimePolicy,
13386    ) -> ProviderRuntimePolicy {
13387        #[cfg(feature = "fixture")]
13388        let runtime_policy = if self.fixture_semantic_hook_policy {
13389            ProviderRuntimePolicy::new(true, true, true, true, false, true)
13390                .expect("monitoring Hook fixture policy is internally valid")
13391        } else {
13392            runtime_policy
13393        };
13394        runtime_policy
13395    }
13396
13397    fn verify_harness_mcp_before_start(
13398        &self,
13399        prepared: Option<&PreparedHarnessMcpSpawn>,
13400    ) -> Result<(), NodeFailure> {
13401        prepared
13402            .map(PreparedHarnessMcpSpawn::verify_helper_program)
13403            .transpose()
13404            .map(|_| ())
13405            .map_err(harness_mcp_failure)
13406    }
13407
13408    async fn rollback_spawn(
13409        &self,
13410        address: &SessionAddress,
13411        commit_timeout: Duration,
13412    ) -> Result<(), NodeFailure> {
13413        self.dispatch_bounded(
13414            ControlCommand::Remove {
13415                instance_id: address.session.instance_id,
13416            },
13417            commit_timeout,
13418        )
13419        .await?;
13420        self.wait_until_removed(address, commit_timeout).await?;
13421        if let Some(binding) = self.remove_binding(address) {
13422            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13423        }
13424        Ok(())
13425    }
13426
13427    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13428        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13429        self.dispatch_bounded(
13430            ControlCommand::Remove {
13431                instance_id: address.session.instance_id,
13432            },
13433            commit_timeout,
13434        )
13435            .await?;
13436        self.wait_until_removed(address, commit_timeout).await?;
13437        let removed = self.remove_binding(address);
13438        if let Some(binding) = removed.as_ref() {
13439            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13440        }
13441        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13442            let snapshot = self.managed_worktrees.lock()
13443                .unwrap_or_else(|poisoned| poisoned.into_inner())
13444                .release_session(
13445                    &lease_id,
13446                    self.incarnation_id,
13447                    address.session.instance_id,
13448                    address.session.generation,
13449                    unix_time_ms(),
13450                );
13451            if self.persist_state().is_err() {
13452                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13453            }
13454            if let Some(snapshot) = snapshot {
13455                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13456            }
13457            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13458        }
13459        Ok(())
13460    }
13461
13462    /// Polls until `address`'s session leaves the engine's own session map,
13463    /// or the bounded deadline expires.
13464    ///
13465    /// A `Remove` sent while the session is not yet in a terminal status
13466    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
13467    /// once by the engine (`SessionStatus::allows_remove`) and
13468    /// then discarded -- nothing re-issues it on its own, so the removal
13469    /// that would satisfy this wait never arrives and the caller was
13470    /// guaranteed to time out even though the session settles microseconds
13471    /// later. So this re-dispatches on every poll tick: a session that
13472    /// settles mid-wait -- the common case right after a `Stop` -- still
13473    /// gets removed inside the same bounded deadline.
13474    ///
13475    /// It re-dispatches only once the status will ACCEPT the command. A
13476    /// rejected re-dispatch is not the harmless no-op this loop first
13477    /// assumed: every rejection publishes a `ControlEventKind::Command
13478    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
13479    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
13480    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
13481    /// out to the harness and every attached client. Gated, the same
13482    /// teardown sends exactly one `Remove`, at the first tick the engine
13483    /// can act on it.
13484    ///
13485    /// The status is read from the snapshot this loop already takes, so
13486    /// the gate costs nothing extra. It races -- the status can move
13487    /// between the read and the dispatch -- and that is fine: a rare lost
13488    /// race is one rejection, and the next tick retries.
13489    ///
13490    /// Re-dispatch is separately rate-limited to
13491    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
13492    /// leaves a second burst behind it. Once the session settles, every
13493    /// 2ms tick sent another `Remove` while the accepted one was still in
13494    /// flight, and each of those landed on an instance the engine had
13495    /// already dropped -- `agent instance … is not registered`, observed
13496    /// live as a tail of rejections immediately after the `session
13497    /// removed` line. The presence poll stays at 2ms so the wait still
13498    /// exits promptly; only the sending slows down.
13499    ///
13500    /// Rate-limited rather than once-only on purpose: the status gate
13501    /// covers the status precondition, but `Gate4AgentEngine::remove` has
13502    /// others (a pending operation, an in-flight capability probe or
13503    /// history request) that clear on their own. A single shot would turn
13504    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
13505    /// get their retries, just twenty of them across the deadline instead
13506    /// of a thousand.
13507    async fn wait_until_removed(
13508        &self,
13509        address: &SessionAddress,
13510        commit_timeout: Duration,
13511    ) -> Result<(), NodeFailure> {
13512        let deadline = Instant::now() + commit_timeout;
13513        let mut next_dispatch = Instant::now();
13514        loop {
13515            let snapshot = self.handle.snapshot();
13516            let status = snapshot
13517                .sessions
13518                .iter()
13519                .find(|session| session.instance_id == address.session.instance_id)
13520                .map(|session| session.status.clone());
13521            let Some(status) = status else {
13522                return Ok(());
13523            };
13524            let now = Instant::now();
13525            if now >= deadline {
13526                return Err(failure(
13527                    NodeFailureCode::BackendBusy,
13528                    "remove did not commit before the bounded deadline; session binding was retained",
13529                ));
13530            }
13531            if status.allows_remove() && now >= next_dispatch {
13532                let _ = self.dispatch(ControlCommand::Remove {
13533                    instance_id: address.session.instance_id,
13534                });
13535                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13536            }
13537            sleep(Duration::from_millis(2)).await;
13538        }
13539    }
13540
13541    fn resync(&self, after_sequence: u64) -> NodeResponse {
13542        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13543        let event_sequence = history.last_sequence;
13544        let oldest_available_sequence = history.replay_floor_sequence;
13545        let events = history
13546            .events
13547            .iter()
13548            .filter(|event| event.sequence > after_sequence)
13549            .cloned()
13550            .collect();
13551        drop(history);
13552        NodeResponse::Resync {
13553            event_sequence,
13554            oldest_available_sequence,
13555            snapshot: self.snapshot(),
13556            events,
13557        }
13558    }
13559}
13560
13561/// Coarse label for one `AgentStreamChunkKindV1`, used only by
13562/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
13563/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
13564/// "name what crossed this boundary" reasoning, one layer upstream of it).
13565fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13566    match kind {
13567        AgentStreamChunkKindV1::Text { .. } => "text",
13568        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13569        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13570        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13571        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13572        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13573        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13574    }
13575}
13576
13577/// Serves this wire on the local endpoint, and -- when `call_home` names a
13578/// relay -- over an outbound connection to it as well.
13579///
13580/// The two live in one future rather than a fourth arm of `run`'s own
13581/// `select!` because they are one concern: this is where the node's wire
13582/// is served, and the direction a given connection was opened in does not
13583/// change what happens on it. It also means the existing graceful drain
13584/// below covers both without being duplicated.
13585///
13586/// Either side finishing ends the pair, which is correct: the listener
13587/// only returns on shutdown or a bind failure, and `call_home_loop` only
13588/// returns on shutdown -- a failed relay connection is retried inside it,
13589/// never propagated out.
13590async fn accept_connections(
13591    endpoint: &str,
13592    call_home: Option<std::net::SocketAddr>,
13593    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13594    shared: Arc<NodeShared>,
13595) -> Result<(), NodeServerError> {
13596    let mut connections = JoinSet::new();
13597    let result = match call_home {
13598        None => accept_connections_inner(endpoint, shared, &mut connections).await,
13599        Some(relay) => {
13600            let listener =
13601                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13602            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13603            tokio::pin!(listener);
13604            tokio::pin!(dialler);
13605            tokio::select! {
13606                result = &mut listener => result,
13607                result = &mut dialler => result,
13608            }
13609        }
13610    };
13611    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13612    while !connections.is_empty() {
13613        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13614        if remaining.is_zero()
13615            || timeout(remaining, connections.join_next()).await.is_err()
13616        {
13617            break;
13618        }
13619    }
13620    connections.abort_all();
13621    while connections.join_next().await.is_some() {}
13622    result
13623}
13624
13625/// Backoff ladder for a relay that will not answer, capped rather than
13626/// unbounded: a node whose relay is down is not broken and should keep
13627/// trying forever, but it should not spend a core doing it.
13628const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13629
13630/// Dials the relay, serves this wire over the connection, and does it
13631/// again when the connection ends.
13632///
13633/// The node is the SERVER on the socket it just opened. That inversion is
13634/// the whole point: `serve_connection` is the same function the local
13635/// listener feeds, and it neither knows nor cares which end placed the
13636/// call. The one thing this direction adds is the preface -- the relay
13637/// accepted a socket and cannot know whose it is, so the node says its
13638/// name before the handshake it would otherwise begin with.
13639///
13640/// Never returns an error. A relay that is down, refusing, or speaking a
13641/// different protocol is a condition to retry, not a reason to take the
13642/// node's local endpoint down with it; the only way out is shutdown.
13643async fn call_home_loop(
13644    relay: std::net::SocketAddr,
13645    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13646    shared: Arc<NodeShared>,
13647) -> Result<(), NodeServerError> {
13648    let mut failures = 0_usize;
13649    loop {
13650        if shared.shutdown.load(Ordering::Acquire) {
13651            return Ok(());
13652        }
13653        let permit = tokio::select! {
13654            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13655                permit.map_err(|_| NodeServerError::PreauthClosed)?
13656            }
13657            _ = shared.shutdown_notify.notified() => return Ok(()),
13658        };
13659        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13660            Ok(()) => {
13661                // A clean end is the relay hanging up, which it does on
13662                // its own restart. Reconnect immediately rather than
13663                // serving the backoff a peer that is plainly there.
13664                failures = 0;
13665                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13666            }
13667            Err(error) => {
13668                failures = failures.saturating_add(1);
13669                tracing::warn!(
13670                    node_id = %shared.node_id,
13671                    %relay,
13672                    cause = %error,
13673                    attempt = failures,
13674                    "call-home connection failed",
13675                );
13676                let backoff = call_home_backoff(failures);
13677                tokio::select! {
13678                    _ = sleep(backoff) => {}
13679                    _ = shared.shutdown_notify.notified() => return Ok(()),
13680                }
13681            }
13682        }
13683    }
13684}
13685
13686/// Idle time before the first keepalive probe on a call-home socket.
13687const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13688/// Interval between keepalive probes once the link has gone quiet.
13689const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13690
13691fn call_home_keepalive() -> socket2::TcpKeepalive {
13692    let keepalive = socket2::TcpKeepalive::new()
13693        .with_time(CALL_HOME_KEEPALIVE_IDLE)
13694        .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13695    #[cfg(not(windows))]
13696    let keepalive = keepalive.with_retries(3);
13697    keepalive
13698}
13699
13700fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13701    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13702}
13703
13704/// Same keepalive profile for any node->relay socket (restream uplink).
13705pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13706    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13707}
13708
13709/// Backoff for the `attempt`-th consecutive failure (1-based), capped.
13710fn call_home_backoff(attempt: usize) -> Duration {
13711    Duration::from_millis(
13712        CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13713    )
13714}
13715
13716
13717/// Dial the call-home relay. Tunnel addresses go through the in-process
13718/// WireGuard dialer when the userspace session is up.
13719async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13720    #[cfg(feature = "wireguard")]
13721    {
13722        let std_stream = tokio::task::spawn_blocking(move || {
13723            gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13724        })
13725        .await
13726        .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13727        .map_err(NodeServerError::CallHomeTransport)?;
13728        std_stream
13729            .set_nonblocking(true)
13730            .map_err(NodeServerError::CallHomeTransport)?;
13731        return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13732    }
13733    #[cfg(not(feature = "wireguard"))]
13734    {
13735        tokio::net::TcpStream::connect(relay)
13736            .await
13737            .map_err(NodeServerError::CallHomeTransport)
13738    }
13739}
13740
13741/// One call-home attempt: connect, name this node, then serve.
13742async fn dial_relay_and_serve(
13743    relay: std::net::SocketAddr,
13744    identity: Option<&hatchery_enroll::NodeIdentity>,
13745    shared: Arc<NodeShared>,
13746    preauth_permit: OwnedSemaphorePermit,
13747) -> Result<(), NodeServerError> {
13748    let mut stream = dial_call_home(relay).await?;
13749    // Nagle off for the same reason the rest of this wire wants it off:
13750    // the traffic is small request/response frames, and coalescing them
13751    // adds latency to every one.
13752    let _ = stream.set_nodelay(true);
13753    // A relay that vanishes without a FIN/RST reaching us (tunnel drop,
13754    // relay-side socket kill whose RST was lost) would otherwise leave this
13755    // socket ESTABLISHED forever: the node mostly reads, so nothing ever
13756    // fails and the reconnect loop never runs. Kernel keepalive turns that
13757    // half-open link into a read error within a bounded time.
13758    if let Err(error) = enable_call_home_keepalive(&stream) {
13759        tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13760    }
13761    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13762        .await
13763        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13764    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13765    let mut session_token = None;
13766    if let Some(identity) = identity {
13767        // An enrolled relay answers the announce with a signed-call-home challenge; a
13768        // token-mode relay sends its wire hello instead. Peek to tell them apart
13769        // without consuming either.
13770        let mut magic = [0_u8; 4];
13771        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13772            .await
13773            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13774            .map_err(NodeServerError::CallHomeTransport)?;
13775        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13776            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13777            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13778                .await
13779                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13780                .map_err(NodeServerError::CallHomeTransport)?;
13781            let (answer, token) = identity
13782                .answer_challenge(shared.node_id.as_str(), &frame)
13783                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13784            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13785            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13786            session_token = Some(token);
13787        }
13788    }
13789    serve_connection(stream, shared, session_token, preauth_permit).await
13790}
13791
13792async fn accept_connections_inner(
13793    endpoint: &str,
13794    shared: Arc<NodeShared>,
13795    connections: &mut JoinSet<Result<(), NodeServerError>>,
13796) -> Result<(), NodeServerError> {
13797    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13798    loop {
13799        while connections.try_join_next().is_some() {}
13800        if shared.shutdown.load(Ordering::Acquire) {
13801            return Ok(());
13802        }
13803        let preauth_permit = tokio::select! {
13804            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13805                permit.map_err(|_| NodeServerError::PreauthClosed)?
13806            }
13807            _ = shared.shutdown_notify.notified() => return Ok(()),
13808        };
13809        let server = tokio::select! {
13810            result = listener.accept() => result?,
13811            _ = shared.shutdown_notify.notified() => return Ok(()),
13812        };
13813        let connection_shared = Arc::clone(&shared);
13814        connections.spawn(async move {
13815            serve_connection(server, connection_shared, None, preauth_permit).await
13816        });
13817        if shared.shutdown.load(Ordering::Acquire) {
13818            return Ok(());
13819        }
13820    }
13821}
13822
13823/// Serves one connection, whichever end opened it.
13824///
13825/// Generic over the stream rather than typed to `LocalServerStream`
13826/// because a call-home connection arrives on a `TcpStream` the node itself
13827/// dialled. Nothing in the body ever depended on the concrete type -- it
13828/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13829/// the whole of what welding "who dials" to "who serves" was costing.
13830async fn serve_connection<S>(
13831    mut pipe: S,
13832    shared: Arc<NodeShared>,
13833    session_token: Option<String>,
13834    preauth_permit: OwnedSemaphorePermit,
13835) -> Result<(), NodeServerError>
13836where
13837    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13838{
13839    // A signed call-home connection authenticates with its own per-connection token.
13840    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13841    let hello_frame = timeout(
13842        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13843        read_json_frame_limited_body_timeout(
13844            &mut pipe,
13845            MAX_NODE_HELLO_FRAME_BYTES,
13846            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13847        ),
13848    )
13849    .await
13850    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13851    let ClientFrame::Hello(hello) = hello_frame else {
13852        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13853    };
13854    if hello.build_stamp != BUILD_STAMP {
13855        return Err(NodeServerError::Handshake(format!(
13856            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13857            hello.build_stamp,
13858        )));
13859    }
13860    let compatibility = match hello.compatibility.as_ref() {
13861        Some(offer) => {
13862            let mut selected = node_compatibility_support(&shared)?
13863                .negotiate(offer)
13864                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13865            project_negotiated_provider_ids(&mut selected);
13866            Some(selected)
13867        }
13868        None => None,
13869    };
13870    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13871    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13872        (Some(offer), Some(selected)) => negotiated_auth_proof(
13873            auth_token,
13874            AuthDirection::Server,
13875            hello.role,
13876            &hello.client_nonce,
13877            &server_nonce,
13878            offer,
13879            selected,
13880        ),
13881        (None, None) => auth_proof(
13882            auth_token,
13883            AuthDirection::Server,
13884            hello.role,
13885            &hello.client_nonce,
13886            &server_nonce,
13887        ),
13888        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13889    }
13890    .map_err(NodeServerError::Authentication)?;
13891    write_json_frame_limited(
13892        &mut pipe,
13893        &ServerFrame::Challenge(ServerChallenge {
13894            build_stamp: BUILD_STAMP.to_owned(),
13895            server_nonce,
13896            server_proof,
13897            compatibility: compatibility.clone(),
13898        }),
13899        MAX_NODE_HELLO_FRAME_BYTES,
13900    )
13901    .await?;
13902    let authentication = timeout(
13903        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13904        read_json_frame_limited_body_timeout(
13905            &mut pipe,
13906            MAX_NODE_HELLO_FRAME_BYTES,
13907            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13908        ),
13909    )
13910    .await
13911    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13912    let ClientFrame::Authenticate(authentication) = authentication else {
13913        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13914    };
13915    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13916        (Some(offer), Some(selected)) => negotiated_auth_proof(
13917            auth_token,
13918            AuthDirection::Client,
13919            hello.role,
13920            &hello.client_nonce,
13921            &server_nonce,
13922            offer,
13923            selected,
13924        ),
13925        (None, None) => auth_proof(
13926            auth_token,
13927            AuthDirection::Client,
13928            hello.role,
13929            &hello.client_nonce,
13930            &server_nonce,
13931        ),
13932        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13933    }
13934    .map_err(NodeServerError::Authentication)?;
13935    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13936        return Err(NodeServerError::Handshake("access denied".to_owned()));
13937    }
13938    let selected_capabilities = compatibility
13939        .as_ref()
13940        .map(|selected| selected.capabilities.clone())
13941        .unwrap_or_default();
13942    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13943        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13944    });
13945    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13946        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13947    });
13948    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13949        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13950    });
13951    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13952        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13953    });
13954    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13955        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13956    });
13957    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13958        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13959    }) && selected_capabilities.iter().any(|capability| {
13960        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13961    });
13962    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13963        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13964    });
13965    let include_session_bundles = selected_capabilities.iter().any(|capability| {
13966        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13967    });
13968    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13969        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13970    });
13971    let include_agent_progress = selected_capabilities.iter().any(|capability| {
13972        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13973    });
13974    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13975        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13976    });
13977    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13978        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13979    });
13980    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13981        .try_acquire_owned()
13982        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13983    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13984    let _release_guard = ControllerReleaseGuard {
13985        shared: Arc::clone(&shared),
13986        connection_id,
13987    };
13988    drop(preauth_permit);
13989    let _authenticated_permit = authenticated_permit;
13990    let mut event_rx = shared.event_tx.subscribe();
13991    let mut terminal_event_rx = terminal_event_subscription(
13992        &shared,
13993        include_terminal_frame_events,
13994    );
13995    let mut agent_stream_event_rx = agent_stream_event_subscription(
13996        &shared,
13997        include_agent_stream_events,
13998    );
13999    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14000        &shared,
14001        include_harness_mcp_proxy,
14002    );
14003    write_json_frame(
14004        &mut pipe,
14005        &ServerFrame::Hello(NodeHello {
14006            build_stamp: BUILD_STAMP.to_owned(),
14007            incarnation_id: shared.incarnation_id,
14008            connection_id,
14009            role: hello.role,
14010            event_sequence: shared.current_sequence(),
14011            controller: shared.controller_state(),
14012            snapshot: snapshot_for_wire(
14013                &shared,
14014                include_provider_runtime_status,
14015                include_open_provider_ids,
14016                include_spawn_profiles,
14017                include_managed_worktrees,
14018                include_child_environment_profiles,
14019                include_session_bundles,
14020                include_history_context_packs,
14021                include_agent_progress,
14022                include_session_task_correlation,
14023            ),
14024            compatibility,
14025        }),
14026    )
14027    .await?;
14028    let (mut reader, mut writer) = tokio::io::split(pipe);
14029    let (frame_tx, mut frame_rx) = mpsc::channel(16);
14030    let reader_task = tokio::spawn(async move {
14031        loop {
14032            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14033                &mut reader,
14034                MAX_NODE_CLIENT_FRAME_BYTES,
14035                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14036            )
14037            .await;
14038            let terminal = frame.is_err();
14039            if frame_tx.send(frame).await.is_err() || terminal {
14040                break;
14041            }
14042        }
14043    });
14044    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14045    let mut pending_events = Vec::new();
14046    let mut resync_required = false;
14047    let mut discard_events_through = 0_u64;
14048    loop {
14049        // One tick of the serve loop, whether or not it had anything to do.
14050        // Against `connection_events_sent` this separates "busy" from
14051        // "spinning": a loop turning over thousands of times a second while
14052        // sending nothing is not serving, it is burning a core.
14053        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14054        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14055            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14056        for _ in 0..durable_drain_budget {
14057            match event_rx.try_recv() {
14058                Ok(event) => queue_connection_event(
14059                    &mut pending_events,
14060                    event,
14061                    discard_events_through,
14062                ),
14063                Err(broadcast::error::TryRecvError::Empty) => break,
14064                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14065                    resync_required = true;
14066                    break;
14067                }
14068                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14069            }
14070        }
14071        if let Some(receiver) = terminal_event_rx.as_mut() {
14072            match receiver.try_recv() {
14073                Ok(events) => queue_connection_event_batch(
14074                    &mut pending_events,
14075                    &events,
14076                    discard_events_through,
14077                ),
14078                Err(broadcast::error::TryRecvError::Empty) => {}
14079                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14080                    resync_required = true;
14081                }
14082                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14083            }
14084        }
14085        if let Some(receiver) = agent_stream_event_rx.as_mut() {
14086            // Loops up to the same remaining-room budget the durable
14087            // `event_rx` drain above uses, rather than one `try_recv` per
14088            // tick: each successful receive here is itself only ONE
14089            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
14090            // sends `vec![envelope]`, never more), so a single-shot drain
14091            // caps this connection's agent-stream throughput at one chunk
14092            // per outer-loop tick regardless of how many are already queued
14093            // -- exactly the kind of self-inflicted stall this crate's own
14094            // durable-event budget exists to avoid.
14095            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14096                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14097            for _ in 0..agent_stream_drain_budget {
14098                match receiver.try_recv() {
14099                    Ok(events) => queue_connection_event_batch(
14100                        &mut pending_events,
14101                        &events,
14102                        discard_events_through,
14103                    ),
14104                    Err(broadcast::error::TryRecvError::Empty) => break,
14105                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14106                        tracing::warn!(
14107                            connection_id,
14108                            dropped,
14109                            "agent stream event subscription lagged; forcing a resync",
14110                        );
14111                        resync_required = true;
14112                        break;
14113                    }
14114                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14115                }
14116            }
14117        }
14118        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14119            // Mirrors the agent-stream drain immediately above: each
14120            // successful receive is one single-envelope batch
14121            // (`publish_transient` always sends `vec![envelope]`), so this
14122            // loops up to the same remaining-room budget rather than
14123            // stopping after one `try_recv`.
14124            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14125                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14126            for _ in 0..harness_mcp_proxy_drain_budget {
14127                match receiver.try_recv() {
14128                    Ok(events) => queue_connection_event_batch(
14129                        &mut pending_events,
14130                        &events,
14131                        discard_events_through,
14132                    ),
14133                    Err(broadcast::error::TryRecvError::Empty) => break,
14134                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14135                        tracing::warn!(
14136                            connection_id,
14137                            dropped,
14138                            "harness MCP proxy event subscription lagged; forcing a resync",
14139                        );
14140                        resync_required = true;
14141                        break;
14142                    }
14143                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14144                }
14145            }
14146        }
14147        if resync_required {
14148            let event = resync_required_event(&shared);
14149            apply_connection_resync_watermark(
14150                &mut pending_events,
14151                &mut discard_events_through,
14152                event.sequence,
14153            );
14154            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14155            resync_required = false;
14156            continue;
14157        }
14158        if !pending_events.is_empty() {
14159            pending_events.sort_unstable_by_key(|event| event.sequence);
14160            let burst_len = connection_event_burst_len(pending_events.len());
14161            shared
14162                .connection_events_sent
14163                .fetch_add(burst_len as u64, Ordering::Relaxed);
14164            for event in pending_events.drain(..burst_len) {
14165                let event = if include_managed_worktrees {
14166                    Some(event)
14167                } else {
14168                    project_event_without_managed_worktrees(event)
14169                };
14170                let event = event.and_then(|event| if include_open_provider_ids {
14171                    Some(event)
14172                } else {
14173                    project_event_legacy_provider_ids(&shared, event)
14174                });
14175                let event = event.map(|event| if include_child_environment_profiles {
14176                    event
14177                } else {
14178                    project_event_without_child_environment_profile(event)
14179                });
14180                let event = event.map(|event| if include_session_bundles {
14181                    event
14182                } else {
14183                    project_event_without_session_bundle(event)
14184                });
14185                let event = event.map(|event| {
14186                    project_event_history_for_wire(event, include_history_context_packs)
14187                });
14188                let event = event.map(|event| if include_history_context_packs {
14189                    event
14190                } else {
14191                    project_event_without_context_pack(event)
14192                });
14193                let event = event.map(|event| if include_session_task_correlation {
14194                    event
14195                } else {
14196                    project_event_without_session_task_binding(event)
14197                });
14198                let exact_harness_controller = hello.role == ClientRole::Operator
14199                    && shared.controller_state().is_some_and(|controller| {
14200                        controller.connection_id == connection_id
14201                    });
14202                let event = event.and_then(|event| {
14203                    if include_harness_mcp_proxy && exact_harness_controller {
14204                        Some(event)
14205                    } else {
14206                        project_event_without_harness_mcp_proxy(event)
14207                    }
14208                });
14209                if let Some(event) = event {
14210                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14211                }
14212            }
14213        }
14214        tokio::select! {
14215            biased;
14216            frame = frame_rx.recv() => {
14217                let frame = match frame {
14218                    Some(frame) => frame,
14219                    None => break,
14220                };
14221                let frame = match frame {
14222                    Ok(frame) => frame,
14223                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14224                    Err(error) => return Err(error.into()),
14225                };
14226                let ClientFrame::Request(request) = frame else {
14227                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14228                };
14229                #[cfg(feature = "fixture")]
14230                let probe_spawn_managed_worktree_v2 = matches!(
14231                    &request.request,
14232                    NodeRequest::SpawnManagedWorktreeV2 { .. }
14233                );
14234                let requires_open_provider_ids =
14235                    request_requires_open_provider_ids(&shared, &request.request);
14236                let requires_child_environment_profile =
14237                    request_requires_child_environment_profile(&shared, &request.request);
14238                let requires_session_bundle =
14239                    request_requires_session_bundle(&shared, &request.request);
14240                let requires_history_context_pack =
14241                    request_requires_history_context_pack(&shared, &request.request);
14242                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14243                    ResponseEnvelope {
14244                        request_id: request.request_id,
14245                        result: Err(failure(
14246                            NodeFailureCode::UnsupportedCapability,
14247                            "open provider IDs were not negotiated",
14248                        )),
14249                    }
14250                } else if requires_child_environment_profile
14251                    && !include_child_environment_profiles
14252                {
14253                    ResponseEnvelope {
14254                        request_id: request.request_id,
14255                        result: Err(failure(
14256                            NodeFailureCode::UnsupportedCapability,
14257                            "child environment profiles were not negotiated",
14258                        )),
14259                    }
14260                } else if requires_session_bundle && !include_session_bundles {
14261                    ResponseEnvelope {
14262                        request_id: request.request_id,
14263                        result: Err(failure(
14264                            NodeFailureCode::UnsupportedCapability,
14265                            "session bundles were not negotiated",
14266                        )),
14267                    }
14268                } else if requires_history_context_pack && !include_history_context_packs {
14269                    ResponseEnvelope {
14270                        request_id: request.request_id,
14271                        result: Err(failure(
14272                            NodeFailureCode::UnsupportedCapability,
14273                            "history context packs were not negotiated",
14274                        )),
14275                    }
14276                } else if request_uses_unnegotiated_capability(
14277                    &request.request,
14278                    &selected_capabilities,
14279                )
14280                {
14281                    ResponseEnvelope {
14282                        request_id: request.request_id,
14283                        result: Err(failure(
14284                            NodeFailureCode::UnsupportedCapability,
14285                            "request capability was not negotiated",
14286                        )),
14287                    }
14288                } else {
14289                    process_request(&shared, connection_id, hello.role, request).await
14290                };
14291                #[cfg(feature = "fixture")]
14292                if probe_spawn_managed_worktree_v2 {
14293                    shared
14294                        .fixture_spawn_managed_worktree_v2_failure_probe
14295                        .record(reply.result.as_ref().err().map(|failure| failure.code));
14296                }
14297                if !include_provider_runtime_status {
14298                    clear_response_provider_runtime_status(&mut reply);
14299                }
14300                if !include_managed_worktrees {
14301                    project_response_without_managed_worktrees(&mut reply);
14302                }
14303                if !include_open_provider_ids {
14304                    project_response_legacy_provider_ids(&shared, &mut reply);
14305                }
14306                if !include_child_environment_profiles {
14307                    project_response_without_child_environment_profile(&mut reply);
14308                }
14309                if !include_session_bundles {
14310                    project_response_without_session_bundle(&mut reply);
14311                }
14312                project_response_history_for_wire(
14313                    &mut reply,
14314                    include_history_context_packs,
14315                );
14316                if !include_history_context_packs {
14317                    project_response_without_context_pack(&mut reply);
14318                }
14319                if !include_agent_progress {
14320                    project_response_without_agent_progress(&mut reply);
14321                }
14322                if !include_session_task_correlation {
14323                    project_response_without_session_task_binding(&mut reply);
14324                }
14325                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14326            }
14327            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14328            event = event_rx.recv() => {
14329                match event {
14330                    Ok(event) => queue_connection_event(
14331                        &mut pending_events,
14332                        event,
14333                        discard_events_through,
14334                    ),
14335                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14336                    Err(broadcast::error::RecvError::Closed) => break,
14337                }
14338            }
14339            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14340                match events {
14341                    Ok(events) => queue_connection_event_batch(
14342                        &mut pending_events,
14343                        &events,
14344                        discard_events_through,
14345                    ),
14346                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14347                    Err(broadcast::error::RecvError::Closed) => break,
14348                }
14349            }
14350            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14351                match events {
14352                    Ok(events) => queue_connection_event_batch(
14353                        &mut pending_events,
14354                        &events,
14355                        discard_events_through,
14356                    ),
14357                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14358                    Err(broadcast::error::RecvError::Closed) => break,
14359                }
14360            }
14361            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14362                match events {
14363                    Ok(events) => queue_connection_event_batch(
14364                        &mut pending_events,
14365                        &events,
14366                        discard_events_through,
14367                    ),
14368                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14369                    Err(broadcast::error::RecvError::Closed) => break,
14370                }
14371            }
14372            _ = shared.shutdown_notify.notified() => break,
14373        }
14374    }
14375    Ok(())
14376}
14377
14378async fn receive_terminal_event_batch(
14379    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14380) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14381    match receiver {
14382        Some(receiver) => receiver.recv().await,
14383        None => std::future::pending().await,
14384    }
14385}
14386
14387fn terminal_event_subscription(
14388    shared: &NodeShared,
14389    enabled: bool,
14390) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14391    enabled.then(|| shared.terminal_event_tx.subscribe())
14392}
14393
14394/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
14395/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
14396/// this needs a dedicated channel rather than a flag on the terminal one.
14397fn agent_stream_event_subscription(
14398    shared: &NodeShared,
14399    enabled: bool,
14400) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14401    enabled.then(|| shared.agent_stream_event_tx.subscribe())
14402}
14403
14404/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
14405/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
14406/// own doc for why this needs a dedicated channel rather than a ride on
14407/// the durable `event_tx`.
14408fn harness_mcp_proxy_event_subscription(
14409    shared: &NodeShared,
14410    enabled: bool,
14411) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14412    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14413}
14414
14415fn queue_connection_event(
14416    pending: &mut Vec<NodeEventEnvelope>,
14417    event: NodeEventEnvelope,
14418    discard_through: u64,
14419) {
14420    if event.sequence <= discard_through
14421        && !event.event.requires_harness_mcp_proxy_capability()
14422    {
14423        return;
14424    }
14425    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14426        if let Some(index) = pending.iter().position(|current| {
14427            matches!(
14428                &current.event,
14429                NodeEvent::TerminalFrame {
14430                    address: current_address,
14431                    ..
14432                } if current_address == address
14433            )
14434        }) {
14435            pending.remove(index);
14436        }
14437    }
14438    pending.push(event);
14439}
14440
14441fn project_event_without_harness_mcp_proxy(
14442    envelope: NodeEventEnvelope,
14443) -> Option<NodeEventEnvelope> {
14444    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14445}
14446
14447fn queue_connection_event_batch(
14448    pending: &mut Vec<NodeEventEnvelope>,
14449    events: &[NodeEventEnvelope],
14450    discard_through: u64,
14451) {
14452    for event in events {
14453        queue_connection_event(pending, event.clone(), discard_through);
14454    }
14455}
14456
14457fn apply_connection_resync_watermark(
14458    pending: &mut Vec<NodeEventEnvelope>,
14459    discard_through: &mut u64,
14460    marker_sequence: u64,
14461) {
14462    *discard_through = (*discard_through).max(marker_sequence);
14463    pending.retain(|event| event.sequence > *discard_through);
14464}
14465
14466fn connection_event_burst_len(pending_len: usize) -> usize {
14467    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14468}
14469
14470fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14471    let history = shared
14472        .history
14473        .lock()
14474        .unwrap_or_else(|poisoned| poisoned.into_inner());
14475    let sequence = history.last_sequence;
14476    let oldest_available_sequence = history.replay_floor_sequence;
14477    NodeEventEnvelope {
14478        sequence,
14479        event: NodeEvent::ResyncRequired {
14480            oldest_available_sequence,
14481        },
14482    }
14483}
14484
14485fn node_compatibility_support(
14486    shared: &NodeShared,
14487) -> Result<NodeCompatibilitySupport, NodeServerError> {
14488    let mut support = node_compatibility_support_for_manifest(
14489        &shared.provider_contracts,
14490        &shared.provider_adapter_contracts,
14491    )?;
14492    if shared
14493        .delivery_store
14494        .lock()
14495        .unwrap_or_else(|poisoned| poisoned.into_inner())
14496        .is_some()
14497    {
14498        support.capabilities.push(
14499            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14500                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14501        );
14502    }
14503    if shared.harness_mcp_registry.is_some() {
14504        support.capabilities.push(
14505            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14506                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14507        );
14508    }
14509    Ok(support)
14510}
14511
14512fn node_compatibility_support_for_manifest(
14513    provider_contracts: &[ProviderContractSupport],
14514    provider_adapter_contracts: &[ProviderAdapterContractSupport],
14515) -> Result<NodeCompatibilitySupport, NodeServerError> {
14516    Ok(NodeCompatibilitySupport {
14517        build_stamp: BUILD_STAMP.to_owned(),
14518        capabilities: baseline_capabilities()?,
14519        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14520        path_semantics: platform::path_semantics(),
14521        local_transport: platform::local_transport(),
14522        state_schema: StateSchemaSupport {
14523            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14524                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14525        },
14526        provider_contracts: provider_contracts.to_vec(),
14527        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14528    })
14529}
14530
14531fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14532    let capabilities = [
14533        NODE_COMPATIBILITY_METADATA_CAPABILITY,
14534        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14535        NODE_REPOSITORY_PATH_CAPABILITY,
14536        NODE_WORKSPACE_FILE_READ_CAPABILITY,
14537        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14538        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14539        NODE_GIT_READ_CAPABILITY,
14540        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14541        NODE_PROVIDER_ID_OPEN_CAPABILITY,
14542        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14543        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14544        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14545        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14546        NODE_ACP_CONTROL_CAPABILITY,
14547        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14548        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14549        NODE_WORKTREE_SELECTION_CAPABILITY,
14550        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14551        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14552        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14553        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14554        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14555        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14556        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14557        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14558        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14559        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14560        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14561        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14562    ]
14563        .into_iter()
14564        .map(|capability| {
14565            CapabilityId::new(capability)
14566                .map_err(|error| NodeServerError::Handshake(error.to_string()))
14567        })
14568        .collect::<Result<Vec<_>, _>>()?;
14569    #[cfg(windows)]
14570    let capabilities = {
14571        let mut capabilities = capabilities;
14572        capabilities.push(
14573            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14574                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14575        );
14576        capabilities
14577    };
14578    Ok(capabilities)
14579}
14580
14581fn request_uses_unnegotiated_capability(
14582    request: &NodeRequest,
14583    selected_capabilities: &[CapabilityId],
14584) -> bool {
14585    let selected = |required: &str| {
14586        selected_capabilities
14587            .iter()
14588            .any(|capability| capability.as_str() == required)
14589    };
14590    request
14591        .required_capability()
14592        .is_some_and(|required| !selected(required))
14593        || (request.requires_worktree_selection_capability()
14594            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14595        || (request.requires_spawn_spec_defaults_overrides_capability()
14596            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14597        || (request.requires_spawn_profile_revision_capability()
14598            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14599        || (request.requires_child_environment_profile_capability()
14600            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14601        || (request.requires_session_bundle_materialization_capability()
14602            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14603        || (request.requires_history_context_pack_capability()
14604            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14605}
14606
14607fn snapshot_for_wire(
14608    shared: &NodeShared,
14609    include_provider_runtime_status: bool,
14610    include_open_provider_ids: bool,
14611    include_spawn_profiles: bool,
14612    include_managed_worktrees: bool,
14613    include_child_environment_profiles: bool,
14614    include_session_bundles: bool,
14615    include_history_context_packs: bool,
14616    include_agent_progress: bool,
14617    include_session_task_correlation: bool,
14618) -> NodeSnapshot {
14619    let mut snapshot = shared.snapshot();
14620    if !include_provider_runtime_status {
14621        snapshot.provider_runtime_statuses.clear();
14622    }
14623    if !include_open_provider_ids {
14624        project_snapshot_legacy_provider_ids(&mut snapshot);
14625    }
14626    if !include_managed_worktrees {
14627        snapshot.managed_worktrees.clear();
14628        for workspace in &mut snapshot.workspaces {
14629            workspace.managed_worktree_profiles = None;
14630        }
14631    }
14632    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14633        if !include_spawn_profiles {
14634            inventory.spawn_profiles = None;
14635        }
14636        if !include_session_bundles {
14637            inventory.bundles = None;
14638        }
14639        if inventory.spawn_profiles.is_none()
14640            && inventory.bundles.is_none()
14641            && inventory.network_allowlists.is_none()
14642        {
14643            snapshot.launch_inventory = None;
14644        }
14645    }
14646    if !include_child_environment_profiles {
14647        clear_snapshot_child_environment_profiles(&mut snapshot);
14648    }
14649    if !include_session_bundles {
14650        clear_snapshot_session_bundles(&mut snapshot);
14651    }
14652    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14653    if !include_history_context_packs {
14654        clear_snapshot_context_packs(&mut snapshot);
14655    }
14656    if !include_agent_progress {
14657        snapshot.agent_progress.clear();
14658    }
14659    if !include_session_task_correlation {
14660        clear_snapshot_session_task_bindings(&mut snapshot);
14661    }
14662    snapshot
14663}
14664
14665fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14666    for record in &mut snapshot.session_records {
14667        record.task_binding = None;
14668    }
14669}
14670
14671fn project_event_without_session_task_binding(
14672    mut envelope: NodeEventEnvelope,
14673) -> NodeEventEnvelope {
14674    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14675        record.task_binding = None;
14676    }
14677    envelope
14678}
14679
14680fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14681    let Ok(response) = reply.result.as_mut() else { return };
14682    match response {
14683        NodeResponse::Snapshot { snapshot, .. } => {
14684            clear_snapshot_session_task_bindings(snapshot);
14685        }
14686        NodeResponse::Resync { snapshot, events, .. } => {
14687            clear_snapshot_session_task_bindings(snapshot);
14688            for event in events {
14689                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14690                    record.task_binding = None;
14691                }
14692            }
14693        }
14694        NodeResponse::SessionRecordUpdated { record }
14695        | NodeResponse::ProviderSessionIndexed { record }
14696        | NodeResponse::NativeSessionIndexed { record, .. }
14697        | NodeResponse::SessionRecordResumed { record, .. } => {
14698            record.task_binding = None;
14699        }
14700        _ => {}
14701    }
14702}
14703
14704fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14705    let Ok(response) = reply.result.as_mut() else { return };
14706    match response {
14707        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14708            snapshot.agent_progress.clear();
14709        }
14710        _ => {}
14711    }
14712}
14713
14714fn project_event_without_managed_worktrees(
14715    mut envelope: NodeEventEnvelope,
14716) -> Option<NodeEventEnvelope> {
14717    if matches!(
14718        envelope.event,
14719        NodeEvent::ManagedWorktreeUpserted { .. }
14720            | NodeEvent::ManagedWorktreeRemoved { .. }
14721    ) {
14722        return None;
14723    }
14724    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14725        workspace.managed_worktree_profiles = None;
14726    }
14727    Some(envelope)
14728}
14729
14730fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14731    if matches!(
14732        reply.result,
14733        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14734            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14735    ) {
14736        reply.result = Err(failure(
14737            NodeFailureCode::UnsupportedCapability,
14738            "managed worktree capability was not negotiated",
14739        ));
14740        return;
14741    }
14742    let Ok(response) = reply.result.as_mut() else { return };
14743    match response {
14744        NodeResponse::Snapshot { snapshot, .. } => {
14745            snapshot.managed_worktrees.clear();
14746            for workspace in &mut snapshot.workspaces {
14747                workspace.managed_worktree_profiles = None;
14748            }
14749        }
14750        NodeResponse::Resync { snapshot, events, .. } => {
14751            snapshot.managed_worktrees.clear();
14752            for workspace in &mut snapshot.workspaces {
14753                workspace.managed_worktree_profiles = None;
14754            }
14755            *events = std::mem::take(events)
14756                .into_iter()
14757                .filter_map(project_event_without_managed_worktrees)
14758                .collect();
14759        }
14760        NodeResponse::WorkspaceRegistered { workspace }
14761        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14762        | NodeResponse::WorktreeCreated { workspace, .. } => {
14763            workspace.managed_worktree_profiles = None;
14764        }
14765        NodeResponse::WorkspaceInspected { inspection } => {
14766            inspection.git.managed_worktree = None;
14767        }
14768        _ => {}
14769    }
14770}
14771
14772fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14773    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14774        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14775    });
14776    if includes_open_provider_ids {
14777        return;
14778    }
14779    compatibility
14780        .provider_contracts
14781        .retain(|contract| provider_id_is_legacy(&contract.provider));
14782    compatibility
14783        .provider_adapter_contracts
14784        .retain(|contract| provider_id_is_legacy(&contract.provider));
14785}
14786
14787fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14788    if let Some(spec) = match request {
14789        NodeRequest::SpawnSpec { spec }
14790        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14791        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14792        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14793        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14794        _ => None,
14795    } {
14796        return shared
14797            .resolve_spawn_spec(spec)
14798            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14799    }
14800    request_requires_open_provider_ids_with(
14801        request,
14802        |session| shared.validate_address(session).ok(),
14803        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14804    )
14805}
14806
14807fn request_requires_child_environment_profile(
14808    shared: &NodeShared,
14809    request: &NodeRequest,
14810) -> bool {
14811    if let Some(spec) = match request {
14812        NodeRequest::SpawnSpec { spec }
14813        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14814        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14815        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14816        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14817        _ => None,
14818    } {
14819        return shared
14820            .resolve_spawn_spec(spec)
14821            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14822    }
14823    match request {
14824        NodeRequest::Resume { session, .. }
14825        | NodeRequest::Prompt { session, .. }
14826        | NodeRequest::Paste { session, .. }
14827        | NodeRequest::Input { session, .. }
14828        | NodeRequest::TerminalBytes { session, .. }
14829        | NodeRequest::TerminalControl { session, .. }
14830        | NodeRequest::Resize { session, .. }
14831        | NodeRequest::Interrupt { session }
14832        | NodeRequest::Stop { session, .. }
14833        | NodeRequest::Remove { session }
14834        | NodeRequest::ResolveInteraction { session, .. }
14835        | NodeRequest::SetSessionMode { session, .. }
14836        | NodeRequest::SetSessionConfigOption { session, .. }
14837        | NodeRequest::SetSessionModel { session, .. }
14838        | NodeRequest::DiscoverHistory { session, .. }
14839        | NodeRequest::LoadHistory { session, .. }
14840        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14841        | NodeRequest::ExportContextPack { session } => shared
14842            .session_bindings
14843            .lock()
14844            .unwrap_or_else(|poisoned| poisoned.into_inner())
14845            .get(&session.session.instance_id)
14846            .filter(|binding| {
14847                binding.workspace_id == session.workspace_id
14848                    && binding.generation == session.session.generation
14849            })
14850            .map_or(true, |binding| binding.environment_profile.is_some()),
14851        NodeRequest::RenameSessionRecord { record_id, .. }
14852        | NodeRequest::SetSessionTask { record_id, .. }
14853        | NodeRequest::ResumeSessionRecord { record_id, .. }
14854        | NodeRequest::ForgetSessionRecord { record_id } => shared
14855            .record(record_id)
14856            .map_or(true, |record| record.environment_profile.is_some()),
14857        NodeRequest::ArmHarnessMcpReservation { .. }
14858        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14859        | NodeRequest::ActivateHarnessMcpReservation { .. }
14860        | NodeRequest::AbortHarnessMcpReservation { .. }
14861        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14862        | NodeRequest::RejectHarnessMcpCall { .. }
14863        | NodeRequest::Snapshot
14864        | NodeRequest::Resync { .. }
14865        | NodeRequest::BeginDeliveryStage { .. }
14866        | NodeRequest::PutDeliveryBlobChunk { .. }
14867        | NodeRequest::CommitDeliveryStage { .. }
14868        | NodeRequest::AbortDeliveryStage { .. }
14869        | NodeRequest::BrowseHostDirectories { .. }
14870        | NodeRequest::InspectWorkspace { .. }
14871        | NodeRequest::ReadWorkspaceFile { .. }
14872        | NodeRequest::WriteWorkspaceFile { .. }
14873        | NodeRequest::CreateWorkspaceFile { .. }
14874        | NodeRequest::CreateWorkspaceDirectory { .. }
14875        | NodeRequest::ReadGitHistory { .. }
14876        | NodeRequest::ReadGitDiff { .. }
14877        | NodeRequest::AcquireController { .. }
14878        | NodeRequest::ReleaseController
14879        | NodeRequest::Spawn { .. }
14880        | NodeRequest::SpawnWithOptions { .. }
14881        | NodeRequest::SpawnSpec { .. }
14882        | NodeRequest::RegisterWorkspace { .. }
14883        | NodeRequest::CreateStandaloneWorkspace { .. }
14884        | NodeRequest::UnregisterWorkspace { .. }
14885        | NodeRequest::CreateWorktree { .. }
14886        | NodeRequest::RemoveWorktree { .. }
14887        | NodeRequest::SpawnManagedWorktree { .. }
14888        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14889        | NodeRequest::CleanupManagedWorktree { .. }
14890        | NodeRequest::IndexProviderSession { .. }
14891        | NodeRequest::IndexNativeSession { .. }
14892        | NodeRequest::CatalogNativeSessions { .. }
14893        | NodeRequest::PageNativeSessions { .. }
14894        | NodeRequest::PreviewNativeSession { .. }
14895        | NodeRequest::PreviewSessionRecord { .. }
14896        | NodeRequest::ForgetContextPack { .. }
14897        | NodeRequest::ResolveDurableContextPack { .. }
14898        | NodeRequest::ReadContextPack { .. }
14899        | NodeRequest::Shutdown => false,
14900    }
14901}
14902
14903fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14904    if let Some(spec) = match request {
14905        NodeRequest::SpawnSpec { spec }
14906        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14907        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14908        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14909        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14910        _ => None,
14911    } {
14912        return shared
14913            .resolve_spawn_spec(spec)
14914            .is_ok_and(|resolved| resolved.bundle_id.is_some());
14915    }
14916    match request {
14917        NodeRequest::Resume { session, .. }
14918        | NodeRequest::Prompt { session, .. }
14919        | NodeRequest::Paste { session, .. }
14920        | NodeRequest::Input { session, .. }
14921        | NodeRequest::TerminalBytes { session, .. }
14922        | NodeRequest::TerminalControl { session, .. }
14923        | NodeRequest::Resize { session, .. }
14924        | NodeRequest::Interrupt { session }
14925        | NodeRequest::Stop { session, .. }
14926        | NodeRequest::Remove { session }
14927        | NodeRequest::ResolveInteraction { session, .. }
14928        | NodeRequest::SetSessionMode { session, .. }
14929        | NodeRequest::SetSessionConfigOption { session, .. }
14930        | NodeRequest::SetSessionModel { session, .. }
14931        | NodeRequest::DiscoverHistory { session, .. }
14932        | NodeRequest::LoadHistory { session, .. }
14933        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14934        | NodeRequest::ExportContextPack { session } => shared
14935            .session_bindings
14936            .lock()
14937            .unwrap_or_else(|poisoned| poisoned.into_inner())
14938            .get(&session.session.instance_id)
14939            .filter(|binding| {
14940                binding.workspace_id == session.workspace_id
14941                    && binding.generation == session.session.generation
14942            })
14943            .map_or(true, |binding| binding.bundle.is_some()),
14944        NodeRequest::RenameSessionRecord { record_id, .. }
14945        | NodeRequest::SetSessionTask { record_id, .. }
14946        | NodeRequest::ResumeSessionRecord { record_id, .. }
14947        | NodeRequest::ForgetSessionRecord { record_id } => shared
14948            .record(record_id)
14949            .map_or(true, |record| record.bundle.is_some()),
14950        NodeRequest::ArmHarnessMcpReservation { .. }
14951        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14952        | NodeRequest::ActivateHarnessMcpReservation { .. }
14953        | NodeRequest::AbortHarnessMcpReservation { .. }
14954        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14955        | NodeRequest::RejectHarnessMcpCall { .. }
14956        | NodeRequest::Snapshot
14957        | NodeRequest::Resync { .. }
14958        | NodeRequest::BeginDeliveryStage { .. }
14959        | NodeRequest::PutDeliveryBlobChunk { .. }
14960        | NodeRequest::CommitDeliveryStage { .. }
14961        | NodeRequest::AbortDeliveryStage { .. }
14962        | NodeRequest::BrowseHostDirectories { .. }
14963        | NodeRequest::InspectWorkspace { .. }
14964        | NodeRequest::ReadWorkspaceFile { .. }
14965        | NodeRequest::WriteWorkspaceFile { .. }
14966        | NodeRequest::CreateWorkspaceFile { .. }
14967        | NodeRequest::CreateWorkspaceDirectory { .. }
14968        | NodeRequest::ReadGitHistory { .. }
14969        | NodeRequest::ReadGitDiff { .. }
14970        | NodeRequest::AcquireController { .. }
14971        | NodeRequest::ReleaseController
14972        | NodeRequest::Spawn { .. }
14973        | NodeRequest::SpawnWithOptions { .. }
14974        | NodeRequest::SpawnSpec { .. }
14975        | NodeRequest::RegisterWorkspace { .. }
14976        | NodeRequest::CreateStandaloneWorkspace { .. }
14977        | NodeRequest::UnregisterWorkspace { .. }
14978        | NodeRequest::CreateWorktree { .. }
14979        | NodeRequest::RemoveWorktree { .. }
14980        | NodeRequest::SpawnManagedWorktree { .. }
14981        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14982        | NodeRequest::CleanupManagedWorktree { .. }
14983        | NodeRequest::IndexProviderSession { .. }
14984        | NodeRequest::IndexNativeSession { .. }
14985        | NodeRequest::CatalogNativeSessions { .. }
14986        | NodeRequest::PageNativeSessions { .. }
14987        | NodeRequest::PreviewNativeSession { .. }
14988        | NodeRequest::PreviewSessionRecord { .. }
14989        | NodeRequest::ForgetContextPack { .. }
14990        | NodeRequest::ResolveDurableContextPack { .. }
14991        | NodeRequest::ReadContextPack { .. }
14992        | NodeRequest::Shutdown => false,
14993    }
14994}
14995
14996fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14997    if request.requires_history_context_pack_capability() {
14998        return true;
14999    }
15000    if let Some(spec) = match request {
15001        NodeRequest::SpawnSpec { spec }
15002        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15003        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15004        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15005        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15006        _ => None,
15007    } {
15008        return shared
15009            .resolve_spawn_spec(spec)
15010            .map_or(true, |resolved| resolved.context_id.is_some());
15011    }
15012    match request {
15013        NodeRequest::Resume { session, .. }
15014        | NodeRequest::Prompt { session, .. }
15015        | NodeRequest::Paste { session, .. }
15016        | NodeRequest::Input { session, .. }
15017        | NodeRequest::TerminalBytes { session, .. }
15018        | NodeRequest::TerminalControl { session, .. }
15019        | NodeRequest::Resize { session, .. }
15020        | NodeRequest::Interrupt { session }
15021        | NodeRequest::Stop { session, .. }
15022        | NodeRequest::Remove { session }
15023        | NodeRequest::ResolveInteraction { session, .. }
15024        | NodeRequest::SetSessionMode { session, .. }
15025        | NodeRequest::SetSessionConfigOption { session, .. }
15026        | NodeRequest::SetSessionModel { session, .. } => shared
15027            .session_bindings
15028            .lock()
15029            .unwrap_or_else(|poisoned| poisoned.into_inner())
15030            .get(&session.session.instance_id)
15031            .filter(|binding| {
15032                binding.workspace_id == session.workspace_id
15033                    && binding.generation == session.session.generation
15034            })
15035            .map_or(true, |binding| binding.context.is_some()),
15036        NodeRequest::RenameSessionRecord { record_id, .. }
15037        | NodeRequest::SetSessionTask { record_id, .. }
15038        | NodeRequest::ResumeSessionRecord { record_id, .. }
15039        | NodeRequest::ForgetSessionRecord { record_id } => shared
15040            .record(record_id)
15041            .map_or(true, |record| record.context.is_some()),
15042        NodeRequest::ArmHarnessMcpReservation { .. }
15043        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15044        | NodeRequest::ActivateHarnessMcpReservation { .. }
15045        | NodeRequest::AbortHarnessMcpReservation { .. }
15046        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15047        | NodeRequest::RejectHarnessMcpCall { .. }
15048        | NodeRequest::Snapshot
15049        | NodeRequest::Resync { .. }
15050        | NodeRequest::BeginDeliveryStage { .. }
15051        | NodeRequest::PutDeliveryBlobChunk { .. }
15052        | NodeRequest::CommitDeliveryStage { .. }
15053        | NodeRequest::AbortDeliveryStage { .. }
15054        | NodeRequest::BrowseHostDirectories { .. }
15055        | NodeRequest::InspectWorkspace { .. }
15056        | NodeRequest::ReadWorkspaceFile { .. }
15057        | NodeRequest::WriteWorkspaceFile { .. }
15058        | NodeRequest::CreateWorkspaceFile { .. }
15059        | NodeRequest::CreateWorkspaceDirectory { .. }
15060        | NodeRequest::ReadGitHistory { .. }
15061        | NodeRequest::ReadGitDiff { .. }
15062        | NodeRequest::AcquireController { .. }
15063        | NodeRequest::ReleaseController
15064        | NodeRequest::Spawn { .. }
15065        | NodeRequest::SpawnWithOptions { .. }
15066        | NodeRequest::SpawnSpec { .. }
15067        | NodeRequest::RegisterWorkspace { .. }
15068        | NodeRequest::CreateStandaloneWorkspace { .. }
15069        | NodeRequest::UnregisterWorkspace { .. }
15070        | NodeRequest::CreateWorktree { .. }
15071        | NodeRequest::RemoveWorktree { .. }
15072        | NodeRequest::SpawnManagedWorktree { .. }
15073        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15074        | NodeRequest::CleanupManagedWorktree { .. }
15075        | NodeRequest::IndexProviderSession { .. }
15076        | NodeRequest::IndexNativeSession { .. }
15077        | NodeRequest::CatalogNativeSessions { .. }
15078        | NodeRequest::PageNativeSessions { .. }
15079        | NodeRequest::PreviewNativeSession { .. }
15080        | NodeRequest::PreviewSessionRecord { .. }
15081        | NodeRequest::DiscoverHistory { .. }
15082        | NodeRequest::LoadHistory { .. }
15083        | NodeRequest::ExportContextPackForSessionRecord { .. }
15084        | NodeRequest::ExportContextPack { .. }
15085        | NodeRequest::ForgetContextPack { .. }
15086        | NodeRequest::ResolveDurableContextPack { .. }
15087        | NodeRequest::ReadContextPack { .. }
15088        | NodeRequest::Shutdown => false,
15089    }
15090}
15091
15092fn request_requires_open_provider_ids_with(
15093    request: &NodeRequest,
15094    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15095    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15096) -> bool {
15097    match request {
15098        NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15099        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15100        NodeRequest::CatalogNativeSessions { route, .. }
15101        | NodeRequest::PageNativeSessions { route, .. } => {
15102            !provider_id_is_legacy(&route.provider)
15103        }
15104        NodeRequest::PreviewNativeSession { selection, .. }
15105        | NodeRequest::IndexNativeSession { selection, .. } => {
15106            !provider_id_is_legacy(&selection.route.provider)
15107        }
15108        NodeRequest::SpawnSpec { .. }
15109        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15110        NodeRequest::SpawnManagedWorktree { .. }
15111        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15112        NodeRequest::Resume { session, .. }
15113        | NodeRequest::Prompt { session, .. }
15114        | NodeRequest::Paste { session, .. }
15115        | NodeRequest::Input { session, .. }
15116        | NodeRequest::TerminalBytes { session, .. }
15117        | NodeRequest::TerminalControl { session, .. }
15118        | NodeRequest::Resize { session, .. }
15119        | NodeRequest::Interrupt { session }
15120        | NodeRequest::Stop { session, .. }
15121        | NodeRequest::Remove { session }
15122        | NodeRequest::ResolveInteraction { session, .. }
15123        | NodeRequest::SetSessionMode { session, .. }
15124        | NodeRequest::SetSessionConfigOption { session, .. }
15125        | NodeRequest::SetSessionModel { session, .. }
15126        | NodeRequest::DiscoverHistory { session, .. }
15127        | NodeRequest::LoadHistory { session, .. }
15128        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15129        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15130            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15131        NodeRequest::RenameSessionRecord { record_id, .. }
15132        | NodeRequest::SetSessionTask { record_id, .. }
15133        | NodeRequest::ResumeSessionRecord { record_id, .. }
15134        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15135            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15136        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15137            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15138        NodeRequest::ArmHarnessMcpReservation { .. }
15139        | NodeRequest::ActivateHarnessMcpReservation { .. }
15140        | NodeRequest::AbortHarnessMcpReservation { .. }
15141        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15142        | NodeRequest::RejectHarnessMcpCall { .. }
15143        | NodeRequest::Snapshot
15144        | NodeRequest::Resync { .. }
15145        | NodeRequest::BeginDeliveryStage { .. }
15146        | NodeRequest::PutDeliveryBlobChunk { .. }
15147        | NodeRequest::CommitDeliveryStage { .. }
15148        | NodeRequest::AbortDeliveryStage { .. }
15149        | NodeRequest::BrowseHostDirectories { .. }
15150        | NodeRequest::InspectWorkspace { .. }
15151        | NodeRequest::ReadWorkspaceFile { .. }
15152        | NodeRequest::WriteWorkspaceFile { .. }
15153        | NodeRequest::CreateWorkspaceFile { .. }
15154        | NodeRequest::CreateWorkspaceDirectory { .. }
15155        | NodeRequest::ReadGitHistory { .. }
15156        | NodeRequest::ReadGitDiff { .. }
15157        | NodeRequest::AcquireController { .. }
15158        | NodeRequest::ReleaseController
15159        | NodeRequest::RegisterWorkspace { .. }
15160        | NodeRequest::CreateStandaloneWorkspace { .. }
15161        | NodeRequest::UnregisterWorkspace { .. }
15162        | NodeRequest::CreateWorktree { .. }
15163        | NodeRequest::RemoveWorktree { .. }
15164        | NodeRequest::CleanupManagedWorktree { .. }
15165        | NodeRequest::Shutdown => false,
15166        NodeRequest::ForgetContextPack { .. } => true,
15167        // No session/record to cheaply resolve a provider from here either;
15168        // the resolved receipt can carry an open-id provider, so require the
15169        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
15170        // is the same digest-keyed lookup one step further into the same
15171        // catalog, so it inherits the same conservative answer.
15172        NodeRequest::ResolveDurableContextPack { .. }
15173        | NodeRequest::ReadContextPack { .. } => true,
15174    }
15175}
15176
15177fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15178    snapshot.enabled_providers.retain(provider_id_is_legacy);
15179    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15180        snapshot
15181            .provider_runtime_statuses
15182            .as_slice()
15183            .iter()
15184            .filter(|status| provider_id_is_legacy(status.provider()))
15185            .cloned(),
15186    )
15187    .expect("a filtered provider runtime projection remains valid");
15188    snapshot
15189        .session_records
15190        .retain(|record| provider_id_is_legacy(&record.provider));
15191    for workspace in &mut snapshot.workspaces {
15192        workspace
15193            .sessions
15194            .retain(|session| provider_id_is_legacy(&session.agent_id));
15195    }
15196}
15197
15198fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15199    for record in &mut snapshot.session_records {
15200        record.environment_profile = None;
15201    }
15202    if let Some(profiles) = snapshot
15203        .launch_inventory
15204        .as_mut()
15205        .and_then(|inventory| inventory.spawn_profiles.as_mut())
15206    {
15207        for profile in profiles {
15208            profile.environment_profile = None;
15209        }
15210    }
15211}
15212
15213fn project_event_without_child_environment_profile(
15214    mut envelope: NodeEventEnvelope,
15215) -> NodeEventEnvelope {
15216    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15217        record.environment_profile = None;
15218    }
15219    envelope
15220}
15221
15222fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15223    let contains_environment_profile = match reply.result.as_ref() {
15224        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15225            receipt.environment_profile.is_some()
15226        }
15227        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15228            receipt.spawn.environment_profile.is_some()
15229        }
15230        _ => false,
15231    };
15232    if contains_environment_profile {
15233        reply.result = Err(failure(
15234            NodeFailureCode::UnsupportedCapability,
15235            "child environment profiles were not negotiated",
15236        ));
15237        return;
15238    }
15239    let Ok(response) = reply.result.as_mut() else {
15240        return;
15241    };
15242    match response {
15243        NodeResponse::Snapshot { snapshot, .. } => {
15244            clear_snapshot_child_environment_profiles(snapshot);
15245        }
15246        NodeResponse::Resync { snapshot, events, .. } => {
15247            clear_snapshot_child_environment_profiles(snapshot);
15248            for event in events {
15249                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15250                    record.environment_profile = None;
15251                }
15252            }
15253        }
15254        NodeResponse::SessionRecordUpdated { record }
15255        | NodeResponse::ProviderSessionIndexed { record }
15256        | NodeResponse::NativeSessionIndexed { record, .. }
15257        | NodeResponse::SessionRecordResumed { record, .. } => {
15258            record.environment_profile = None;
15259        }
15260        NodeResponse::Armed { .. }
15261        | NodeResponse::Spawned { .. }
15262        | NodeResponse::Activated { .. }
15263        | NodeResponse::Aborted { .. }
15264        | NodeResponse::ReplyChunkAccepted { .. }
15265        | NodeResponse::CallRejected { .. }
15266        | NodeResponse::DeliveryStageBegun { .. }
15267        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15268        | NodeResponse::DeliveryCommitted { .. }
15269        | NodeResponse::DeliveryStageAborted { .. }
15270        | NodeResponse::WorkspaceInspected { .. }
15271        | NodeResponse::HostDirectoriesBrowsed { .. }
15272        | NodeResponse::WorkspaceFileRead { .. }
15273        | NodeResponse::WorkspaceFileWritten { .. }
15274        | NodeResponse::WorkspaceFileCreated { .. }
15275        | NodeResponse::WorkspaceDirectoryCreated { .. }
15276        | NodeResponse::GitHistoryRead { .. }
15277        | NodeResponse::GitDiffRead { .. }
15278        | NodeResponse::Controller { .. }
15279        | NodeResponse::SpawnAccepted { .. }
15280        | NodeResponse::SpawnSpecAccepted { .. }
15281        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15282        | NodeResponse::ManagedWorktreeCleanup { .. }
15283        | NodeResponse::NativeSessionsCataloged { .. }
15284        | NodeResponse::NativeSessionsPaged { .. }
15285        | NodeResponse::NativeSessionPreviewed { .. }
15286        | NodeResponse::SessionRecordPreviewed { .. }
15287        | NodeResponse::HistoryDiscovered { .. }
15288        | NodeResponse::HistoryLoaded { .. }
15289        | NodeResponse::ContextPackForSessionRecordExported { .. }
15290        | NodeResponse::ContextPackExported { .. }
15291        | NodeResponse::ContextPackForgotten { .. }
15292        | NodeResponse::DurableContextPackResolved { .. }
15293        | NodeResponse::ContextPackBytesRead { .. }
15294        | NodeResponse::SessionRecordForgotten { .. }
15295        | NodeResponse::WorkspaceRegistered { .. }
15296        | NodeResponse::StandaloneWorkspaceCreated { .. }
15297        | NodeResponse::WorkspaceUnregistered { .. }
15298        | NodeResponse::WorktreeCreated { .. }
15299        | NodeResponse::WorktreeRemoved { .. }
15300        | NodeResponse::Accepted
15301        | NodeResponse::ShuttingDown => {}
15302    }
15303}
15304
15305fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15306    for record in &mut snapshot.session_records {
15307        record.bundle = None;
15308    }
15309}
15310
15311fn project_event_without_session_bundle(
15312    mut envelope: NodeEventEnvelope,
15313) -> NodeEventEnvelope {
15314    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15315        record.bundle = None;
15316    }
15317    envelope
15318}
15319
15320fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15321    let contains_bundle = match reply.result.as_ref() {
15322        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15323        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15324            receipt.spawn.bundle.is_some()
15325        }
15326        _ => false,
15327    };
15328    if contains_bundle {
15329        reply.result = Err(failure(
15330            NodeFailureCode::UnsupportedCapability,
15331            "session bundles were not negotiated",
15332        ));
15333        return;
15334    }
15335    let Ok(response) = reply.result.as_mut() else {
15336        return;
15337    };
15338    match response {
15339        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15340        NodeResponse::Resync { snapshot, events, .. } => {
15341            clear_snapshot_session_bundles(snapshot);
15342            for event in events {
15343                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15344                    record.bundle = None;
15345                }
15346            }
15347        }
15348        NodeResponse::SessionRecordUpdated { record }
15349        | NodeResponse::ProviderSessionIndexed { record }
15350        | NodeResponse::NativeSessionIndexed { record, .. }
15351        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15352        NodeResponse::Armed { .. }
15353        | NodeResponse::Spawned { .. }
15354        | NodeResponse::Activated { .. }
15355        | NodeResponse::Aborted { .. }
15356        | NodeResponse::ReplyChunkAccepted { .. }
15357        | NodeResponse::CallRejected { .. }
15358        | NodeResponse::DeliveryStageBegun { .. }
15359        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15360        | NodeResponse::DeliveryCommitted { .. }
15361        | NodeResponse::DeliveryStageAborted { .. }
15362        | NodeResponse::WorkspaceInspected { .. }
15363        | NodeResponse::HostDirectoriesBrowsed { .. }
15364        | NodeResponse::WorkspaceFileRead { .. }
15365        | NodeResponse::WorkspaceFileWritten { .. }
15366        | NodeResponse::WorkspaceFileCreated { .. }
15367        | NodeResponse::WorkspaceDirectoryCreated { .. }
15368        | NodeResponse::GitHistoryRead { .. }
15369        | NodeResponse::GitDiffRead { .. }
15370        | NodeResponse::Controller { .. }
15371        | NodeResponse::SpawnAccepted { .. }
15372        | NodeResponse::SpawnSpecAccepted { .. }
15373        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15374        | NodeResponse::ManagedWorktreeCleanup { .. }
15375        | NodeResponse::NativeSessionsCataloged { .. }
15376        | NodeResponse::NativeSessionsPaged { .. }
15377        | NodeResponse::NativeSessionPreviewed { .. }
15378        | NodeResponse::SessionRecordPreviewed { .. }
15379        | NodeResponse::HistoryDiscovered { .. }
15380        | NodeResponse::HistoryLoaded { .. }
15381        | NodeResponse::ContextPackForSessionRecordExported { .. }
15382        | NodeResponse::ContextPackExported { .. }
15383        | NodeResponse::ContextPackForgotten { .. }
15384        | NodeResponse::DurableContextPackResolved { .. }
15385        | NodeResponse::ContextPackBytesRead { .. }
15386        | NodeResponse::SessionRecordForgotten { .. }
15387        | NodeResponse::WorkspaceRegistered { .. }
15388        | NodeResponse::StandaloneWorkspaceCreated { .. }
15389        | NodeResponse::WorkspaceUnregistered { .. }
15390        | NodeResponse::WorktreeCreated { .. }
15391        | NodeResponse::WorktreeRemoved { .. }
15392        | NodeResponse::Accepted
15393        | NodeResponse::ShuttingDown => {}
15394    }
15395}
15396
15397fn project_snapshot_history_for_wire(
15398    snapshot: &mut NodeSnapshot,
15399    include_history_metadata: bool,
15400) {
15401    for workspace in &mut snapshot.workspaces {
15402        for session in &mut workspace.sessions {
15403            if include_history_metadata {
15404                session.history.loaded = None;
15405            } else {
15406                session.history = Default::default();
15407            }
15408        }
15409    }
15410}
15411
15412fn project_event_history_for_wire(
15413    mut envelope: NodeEventEnvelope,
15414    include_history_metadata: bool,
15415) -> NodeEventEnvelope {
15416    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15417        for session in &mut workspace.sessions {
15418            if include_history_metadata {
15419                session.history.loaded = None;
15420            } else {
15421                session.history = Default::default();
15422            }
15423        }
15424    }
15425    envelope
15426}
15427
15428fn project_response_history_for_wire(
15429    reply: &mut ResponseEnvelope,
15430    include_history_metadata: bool,
15431) {
15432    let Ok(response) = reply.result.as_mut() else {
15433        return;
15434    };
15435    match response {
15436        NodeResponse::Snapshot { snapshot, .. } => {
15437            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15438        }
15439        NodeResponse::Resync {
15440            snapshot, events, ..
15441        } => {
15442            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15443            for event in events {
15444                *event = project_event_history_for_wire(
15445                    event.clone(),
15446                    include_history_metadata,
15447                );
15448            }
15449        }
15450        NodeResponse::WorkspaceRegistered { workspace }
15451        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15452        | NodeResponse::WorktreeCreated { workspace, .. } => {
15453            for session in &mut workspace.sessions {
15454                if include_history_metadata {
15455                    session.history.loaded = None;
15456                } else {
15457                    session.history = Default::default();
15458                }
15459            }
15460        }
15461        _ => {}
15462    }
15463}
15464
15465fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15466    for record in &mut snapshot.session_records {
15467        record.context_id = None;
15468        record.context = None;
15469        record.exported_context = None;
15470    }
15471}
15472
15473fn project_event_without_context_pack(
15474    mut envelope: NodeEventEnvelope,
15475) -> NodeEventEnvelope {
15476    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15477        record.context_id = None;
15478        record.context = None;
15479        record.exported_context = None;
15480    }
15481    envelope
15482}
15483
15484fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15485    let contains_context = match reply.result.as_ref() {
15486        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15487        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15488            receipt.spawn.context.is_some()
15489        }
15490        Ok(NodeResponse::HistoryDiscovered { .. })
15491        | Ok(NodeResponse::HistoryLoaded { .. })
15492        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15493        | Ok(NodeResponse::ContextPackExported { .. })
15494        | Ok(NodeResponse::ContextPackForgotten { .. })
15495        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15496        _ => false,
15497    };
15498    if contains_context {
15499        reply.result = Err(failure(
15500            NodeFailureCode::UnsupportedCapability,
15501            "history context packs were not negotiated",
15502        ));
15503        return;
15504    }
15505    let Ok(response) = reply.result.as_mut() else {
15506        return;
15507    };
15508    match response {
15509        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15510        NodeResponse::Resync {
15511            snapshot, events, ..
15512        } => {
15513            clear_snapshot_context_packs(snapshot);
15514            for event in events {
15515                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15516                    record.context_id = None;
15517                    record.context = None;
15518                    record.exported_context = None;
15519                }
15520            }
15521        }
15522        NodeResponse::SessionRecordUpdated { record }
15523        | NodeResponse::ProviderSessionIndexed { record }
15524        | NodeResponse::NativeSessionIndexed { record, .. }
15525        | NodeResponse::SessionRecordResumed { record, .. } => {
15526            record.context_id = None;
15527            record.context = None;
15528            record.exported_context = None;
15529        }
15530        _ => {}
15531    }
15532}
15533
15534fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15535    let contains_open_record = match reply.result.as_ref() {
15536        Ok(NodeResponse::SessionRecordUpdated { record })
15537        | Ok(NodeResponse::ProviderSessionIndexed { record })
15538        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15539            !provider_id_is_legacy(&record.provider)
15540        }
15541        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15542            !provider_id_is_legacy(&selection.route.provider)
15543                || !provider_id_is_legacy(&record.provider)
15544        }
15545        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15546            !provider_id_is_legacy(&receipt.provider)
15547        }
15548        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15549            !provider_id_is_legacy(&receipt.spawn.provider)
15550        }
15551        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15552        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15553            !provider_id_is_legacy(&route.provider)
15554        }
15555        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15556            !provider_id_is_legacy(&selection.route.provider)
15557        }
15558        _ => false,
15559    };
15560    if contains_open_record {
15561        reply.result = Err(failure(
15562            NodeFailureCode::UnsupportedCapability,
15563            "open provider IDs were not negotiated",
15564        ));
15565        return;
15566    }
15567    let Ok(response) = reply.result.as_mut() else {
15568        return;
15569    };
15570    match response {
15571        NodeResponse::Snapshot { snapshot, .. } => {
15572            project_snapshot_legacy_provider_ids(snapshot);
15573        }
15574        NodeResponse::Resync { snapshot, events, .. } => {
15575            project_snapshot_legacy_provider_ids(snapshot);
15576            *events = std::mem::take(events)
15577                .into_iter()
15578                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15579                .collect();
15580        }
15581        NodeResponse::WorkspaceRegistered { workspace }
15582        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15583        | NodeResponse::WorktreeCreated { workspace, .. } => {
15584            workspace
15585                .sessions
15586                .retain(|session| provider_id_is_legacy(&session.agent_id));
15587        }
15588        NodeResponse::Armed { .. }
15589        | NodeResponse::Spawned { .. }
15590        | NodeResponse::Activated { .. }
15591        | NodeResponse::Aborted { .. }
15592        | NodeResponse::ReplyChunkAccepted { .. }
15593        | NodeResponse::CallRejected { .. }
15594        | NodeResponse::DeliveryStageBegun { .. }
15595        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15596        | NodeResponse::DeliveryCommitted { .. }
15597        | NodeResponse::DeliveryStageAborted { .. }
15598        | NodeResponse::WorkspaceInspected { .. }
15599        | NodeResponse::HostDirectoriesBrowsed { .. }
15600        | NodeResponse::WorkspaceFileRead { .. }
15601        | NodeResponse::WorkspaceFileWritten { .. }
15602        | NodeResponse::WorkspaceFileCreated { .. }
15603        | NodeResponse::WorkspaceDirectoryCreated { .. }
15604        | NodeResponse::GitHistoryRead { .. }
15605        | NodeResponse::GitDiffRead { .. }
15606        | NodeResponse::Controller { .. }
15607        | NodeResponse::SpawnAccepted { .. }
15608        | NodeResponse::SpawnSpecAccepted { .. }
15609        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15610        | NodeResponse::ManagedWorktreeCleanup { .. }
15611        | NodeResponse::NativeSessionsCataloged { .. }
15612        | NodeResponse::NativeSessionsPaged { .. }
15613        | NodeResponse::NativeSessionPreviewed { .. }
15614        | NodeResponse::SessionRecordPreviewed { .. }
15615        | NodeResponse::HistoryDiscovered { .. }
15616        | NodeResponse::HistoryLoaded { .. }
15617        | NodeResponse::ContextPackForSessionRecordExported { .. }
15618        | NodeResponse::ContextPackExported { .. }
15619        | NodeResponse::ContextPackForgotten { .. }
15620        | NodeResponse::DurableContextPackResolved { .. }
15621        | NodeResponse::ContextPackBytesRead { .. }
15622        | NodeResponse::SessionRecordUpdated { .. }
15623        | NodeResponse::ProviderSessionIndexed { .. }
15624        | NodeResponse::NativeSessionIndexed { .. }
15625        | NodeResponse::SessionRecordResumed { .. }
15626        | NodeResponse::SessionRecordForgotten { .. }
15627        | NodeResponse::WorkspaceUnregistered { .. }
15628        | NodeResponse::WorktreeRemoved { .. }
15629        | NodeResponse::Accepted
15630        | NodeResponse::ShuttingDown => {}
15631    }
15632}
15633
15634fn project_event_legacy_provider_ids(
15635    shared: &NodeShared,
15636    mut envelope: NodeEventEnvelope,
15637) -> Option<NodeEventEnvelope> {
15638    let include = match &mut envelope.event {
15639        NodeEvent::HarnessMcpReadCall { .. } => true,
15640        NodeEvent::Control { address, .. }
15641        | NodeEvent::AgentStream { address, .. }
15642        | NodeEvent::TerminalFrame { address, .. } => shared
15643            .validate_address(address)
15644            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15645        NodeEvent::WorkspaceAdded { workspace } => {
15646            workspace
15647                .sessions
15648                .retain(|session| provider_id_is_legacy(&session.agent_id));
15649            true
15650        }
15651        NodeEvent::SessionRecordUpserted { record } => {
15652            provider_id_is_legacy(&record.provider)
15653        }
15654        NodeEvent::SessionRecordHistorySummarized { .. } => false,
15655        NodeEvent::SessionRecordRemoved { .. } => shared
15656            .history
15657            .lock()
15658            .unwrap_or_else(|poisoned| poisoned.into_inner())
15659            .removed_record_providers
15660            .get(&envelope.sequence)
15661            .is_some_and(provider_id_is_legacy),
15662        NodeEvent::ControllerChanged { .. }
15663        | NodeEvent::ManagedWorktreeUpserted { .. }
15664        | NodeEvent::ManagedWorktreeRemoved { .. }
15665        | NodeEvent::WorkspaceRemoved { .. }
15666        | NodeEvent::ResyncRequired { .. } => true,
15667    };
15668    include.then_some(envelope)
15669}
15670
15671fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15672    let Ok(response) = reply.result.as_mut() else {
15673        return;
15674    };
15675    match response {
15676        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15677            snapshot.provider_runtime_statuses.clear();
15678        }
15679        NodeResponse::Armed { .. }
15680        | NodeResponse::Spawned { .. }
15681        | NodeResponse::Activated { .. }
15682        | NodeResponse::Aborted { .. }
15683        | NodeResponse::ReplyChunkAccepted { .. }
15684        | NodeResponse::CallRejected { .. }
15685        | NodeResponse::DeliveryStageBegun { .. }
15686        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15687        | NodeResponse::DeliveryCommitted { .. }
15688        | NodeResponse::DeliveryStageAborted { .. }
15689        | NodeResponse::WorkspaceInspected { .. }
15690        | NodeResponse::HostDirectoriesBrowsed { .. }
15691        | NodeResponse::WorkspaceFileRead { .. }
15692        | NodeResponse::WorkspaceFileWritten { .. }
15693        | NodeResponse::WorkspaceFileCreated { .. }
15694        | NodeResponse::WorkspaceDirectoryCreated { .. }
15695        | NodeResponse::GitHistoryRead { .. }
15696        | NodeResponse::GitDiffRead { .. }
15697        | NodeResponse::Controller { .. }
15698        | NodeResponse::SpawnAccepted { .. }
15699        | NodeResponse::SpawnSpecAccepted { .. }
15700        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15701        | NodeResponse::ManagedWorktreeCleanup { .. }
15702        | NodeResponse::NativeSessionsCataloged { .. }
15703        | NodeResponse::NativeSessionsPaged { .. }
15704        | NodeResponse::NativeSessionPreviewed { .. }
15705        | NodeResponse::SessionRecordPreviewed { .. }
15706        | NodeResponse::HistoryDiscovered { .. }
15707        | NodeResponse::HistoryLoaded { .. }
15708        | NodeResponse::ContextPackForSessionRecordExported { .. }
15709        | NodeResponse::ContextPackExported { .. }
15710        | NodeResponse::ContextPackForgotten { .. }
15711        | NodeResponse::DurableContextPackResolved { .. }
15712        | NodeResponse::ContextPackBytesRead { .. }
15713        | NodeResponse::SessionRecordUpdated { .. }
15714        | NodeResponse::ProviderSessionIndexed { .. }
15715        | NodeResponse::NativeSessionIndexed { .. }
15716        | NodeResponse::SessionRecordResumed { .. }
15717        | NodeResponse::SessionRecordForgotten { .. }
15718        | NodeResponse::WorkspaceRegistered { .. }
15719        | NodeResponse::StandaloneWorkspaceCreated { .. }
15720        | NodeResponse::WorkspaceUnregistered { .. }
15721        | NodeResponse::WorktreeCreated { .. }
15722        | NodeResponse::WorktreeRemoved { .. }
15723        | NodeResponse::Accepted
15724        | NodeResponse::ShuttingDown => {}
15725    }
15726}
15727
15728struct ControllerReleaseGuard {
15729    shared: Arc<NodeShared>,
15730    connection_id: u64,
15731}
15732
15733impl Drop for ControllerReleaseGuard {
15734    fn drop(&mut self) {
15735        self.shared.release_controller(self.connection_id);
15736    }
15737}
15738
15739struct AbortTaskOnDrop(AbortHandle);
15740
15741impl Drop for AbortTaskOnDrop {
15742    fn drop(&mut self) {
15743        self.0.abort();
15744    }
15745}
15746
15747/// Reads an unsigned env-config override, following the pattern of every
15748/// other bounded knob in this module: missing/unparsable/zero falls back to
15749/// `default`, and the resolved value is always clamped to `max` so a bad
15750/// override cannot reopen a bound this budget exists to enforce.
15751fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15752    std::env::var(name)
15753        .ok()
15754        .and_then(|value| value.parse::<u64>().ok())
15755        .filter(|value| *value > 0)
15756        .unwrap_or(default)
15757        .min(max)
15758}
15759
15760fn workspace_inspection_time_budget_ms() -> u64 {
15761    env_config_bounded(
15762        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15763        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15764        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15765    )
15766}
15767
15768fn workspace_inspection_entry_cap() -> usize {
15769    env_config_bounded(
15770        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15771        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15772        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15773    ) as usize
15774}
15775
15776/// Tracks the shared walk-side budget across the whole recursive walk: a
15777/// wall-clock deadline (shared with the git phase that runs after the walk)
15778/// and a cap on directory entries visited, whether or not they were pushed
15779/// to the response. Cheap to check per entry — one `Instant::now()` and one
15780/// integer compare.
15781struct WorkspaceWalkBudget {
15782    deadline: Instant,
15783    entry_cap: usize,
15784    entries_visited: u64,
15785    time_budget_exceeded: bool,
15786    entry_cap_exceeded: bool,
15787}
15788
15789impl WorkspaceWalkBudget {
15790    fn new(deadline: Instant, entry_cap: usize) -> Self {
15791        Self {
15792            deadline,
15793            entry_cap,
15794            entries_visited: 0,
15795            time_budget_exceeded: false,
15796            entry_cap_exceeded: false,
15797        }
15798    }
15799
15800    fn is_exhausted(&self) -> bool {
15801        self.time_budget_exceeded || self.entry_cap_exceeded
15802    }
15803
15804    /// Records one visited directory entry (pushed to the response or not)
15805    /// and reports whether the walk must stop now.
15806    fn record_visit(&mut self) -> bool {
15807        self.entries_visited += 1;
15808        if self.entries_visited as usize >= self.entry_cap {
15809            self.entry_cap_exceeded = true;
15810        }
15811        if Instant::now() >= self.deadline {
15812            self.time_budget_exceeded = true;
15813        }
15814        self.is_exhausted()
15815    }
15816}
15817
15818fn collect_workspace_entries(
15819    root: &Path,
15820    deadline: Instant,
15821    entry_cap: usize,
15822) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15823    let mut entries = Vec::new();
15824    let mut truncated = false;
15825    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15826    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15827    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15828    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15829    // require strict global byte order over relative paths.
15830    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15831    (entries, truncated, budget)
15832}
15833
15834/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15835/// `inspect_workspace` response — `None` once the walk and git phase both
15836/// completed inside their shared budget.
15837fn workspace_inspection_truncation(
15838    walk_budget: &WorkspaceWalkBudget,
15839    git_time_budget_exceeded: bool,
15840    elapsed: Duration,
15841) -> Option<WorkspaceInspectionTruncationV1> {
15842    if !walk_budget.time_budget_exceeded
15843        && !walk_budget.entry_cap_exceeded
15844        && !git_time_budget_exceeded
15845    {
15846        return None;
15847    }
15848    Some(WorkspaceInspectionTruncationV1 {
15849        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15850        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15851        git_time_budget_exceeded,
15852        entries_visited: walk_budget.entries_visited,
15853        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15854    })
15855}
15856
15857fn walk_workspace_directory(
15858    directory: &Path,
15859    relative_directory: Option<&RepositoryPath>,
15860    depth: usize,
15861    entries: &mut Vec<WorkspaceEntry>,
15862    truncated: &mut bool,
15863    budget: &mut WorkspaceWalkBudget,
15864) {
15865    if budget.is_exhausted() {
15866        *truncated = true;
15867        return;
15868    }
15869    let read_dir = match std::fs::read_dir(directory) {
15870        Ok(read_dir) => read_dir,
15871        Err(_) => {
15872            *truncated = true;
15873            return;
15874        }
15875    };
15876    let mut children = Vec::new();
15877    for child in read_dir {
15878        match child {
15879            Ok(child) => children.push(child),
15880            Err(_) => *truncated = true,
15881        }
15882    }
15883    children.sort_by_key(|child| child.file_name());
15884    for child in children {
15885        if budget.record_visit() {
15886            *truncated = true;
15887            return;
15888        }
15889        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15890            *truncated = true;
15891            return;
15892        }
15893        let file_type = match child.file_type() {
15894            Ok(file_type) => file_type,
15895            Err(_) => {
15896                *truncated = true;
15897                continue;
15898            }
15899        };
15900        if file_type.is_symlink() {
15901            continue;
15902        }
15903        let name = match child.file_name().into_string() {
15904            Ok(name) => name,
15905            Err(_) => {
15906                *truncated = true;
15907                continue;
15908            }
15909        };
15910        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15911            continue;
15912        }
15913        let relative_path = match relative_directory {
15914            None => windows_repository_path(name),
15915            Some(parent) => parent
15916                .as_utf8()
15917                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15918        };
15919        let Some(relative_path) = relative_path else {
15920            *truncated = true;
15921            continue;
15922        };
15923        let kind = if file_type.is_dir() {
15924            WorkspaceEntryKind::Directory
15925        } else if file_type.is_file() {
15926            WorkspaceEntryKind::File
15927        } else {
15928            continue;
15929        };
15930        entries.push(WorkspaceEntry {
15931            relative_path: relative_path.clone(),
15932            kind,
15933        });
15934        if file_type.is_dir() {
15935            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15936                walk_workspace_directory(
15937                    &child.path(),
15938                    Some(&relative_path),
15939                    depth + 1,
15940                    entries,
15941                    truncated,
15942                    budget,
15943                );
15944            } else {
15945                *truncated = true;
15946            }
15947        }
15948    }
15949}
15950
15951fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15952    if value.contains('\\') {
15953        return None;
15954    }
15955    RepositoryPath::utf8(value).ok()
15956}
15957
15958fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15959    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15960}
15961
15962/// Excludes a directory from the workspace tree walk. The three literal
15963/// names are the common convention-based cases; the `CACHEDIR.TAG` check
15964/// beneath them catches every build-output directory regardless of name
15965/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
15966/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
15967/// directory without maintaining a name heuristic list). The name check
15968/// runs first and returns early so a directory rejected by name never pays
15969/// for the extra `CACHEDIR.TAG` stat.
15970fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15971    if name.eq_ignore_ascii_case(".git")
15972        || name.eq_ignore_ascii_case("target")
15973        || name.eq_ignore_ascii_case("node_modules")
15974    {
15975        return true;
15976    }
15977    path.join("CACHEDIR.TAG").is_file()
15978}
15979
15980fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15981    matches!(
15982        status,
15983        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15984    )
15985}
15986
15987#[derive(Clone, Debug, Eq, PartialEq)]
15988enum ContextPackRepositoryHead {
15989    NotRepository,
15990    Unborn,
15991    Commit(String),
15992}
15993
15994#[derive(Clone, Debug, Eq, PartialEq)]
15995struct ContextPackRepositoryObservation {
15996    head: ContextPackRepositoryHead,
15997    git: GitSnapshot,
15998    files: Vec<ContextPackRepositoryFileSource>,
15999}
16000
16001fn stable_context_pack_repository(
16002    first: ContextPackRepositoryObservation,
16003    second: ContextPackRepositoryObservation,
16004) -> Result<ContextPackRepository, NodeFailure> {
16005    if first != second {
16006        return Err(failure(
16007            NodeFailureCode::ContextPackMaterializationFailed,
16008            "context repository changed during bounded capture",
16009        ));
16010    }
16011    Ok(ContextPackRepository::from_sources(
16012        &second.git,
16013        second.files,
16014    ))
16015}
16016
16017/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
16018/// (and only once) the shared walk+git `deadline` has already passed. Every
16019/// remaining phase in `inspect_git_workspace` checks this immediately
16020/// before issuing its own git call, so a budget spent by the walk (or by an
16021/// earlier git call) skips the rest of the chain instead of paying for it
16022/// anyway.
16023fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16024    if Instant::now() < deadline {
16025        return false;
16026    }
16027    append_git_diagnostic(
16028        snapshot,
16029        &format!("{stage} skipped: workspace inspection time budget exhausted"),
16030    );
16031    true
16032}
16033
16034/// Deadline-aware sibling of `run_git_bounded`, used only by
16035/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
16036/// whatever remains of the shared `deadline` (never more than
16037/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
16038/// of the budget cannot still hand a full per-call timeout to every git
16039/// probe behind it. `inspect_git_workspace` already guards each call site
16040/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
16041/// passed yet here.
16042async fn run_git_bounded_with_deadline(
16043    root: &str,
16044    arguments: &[&str],
16045    output_limit: usize,
16046    deadline: Instant,
16047) -> io::Result<GitCommandOutput> {
16048    let remaining = deadline.checked_duration_since(Instant::now());
16049    let timeout_ms = remaining
16050        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16051        .unwrap_or(0)
16052        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16053    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16054}
16055
16056/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
16057/// `deadline` is the same absolute instant the preceding directory walk was
16058/// bounded by, so time already spent walking counts against what remains
16059/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
16060/// shared budget (not an individual per-call timeout) is what cut the git
16061/// phase short.
16062async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16063    let mut snapshot = GitSnapshot {
16064        is_repository: false,
16065        branch: None,
16066        status: Vec::new(),
16067        recent_commits: Vec::new(),
16068        worktrees: Vec::new(),
16069        managed_worktree: None,
16070        truncated: false,
16071        diagnostic: None,
16072    };
16073    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16074        return (snapshot, true);
16075    }
16076    let repository = match run_git_bounded_with_deadline(
16077        root,
16078        &["rev-parse", "--is-inside-work-tree"],
16079        4 * 1_024,
16080        deadline,
16081    )
16082    .await
16083    {
16084        Ok(output) => output,
16085        Err(error) => {
16086            append_git_diagnostic(
16087                &mut snapshot,
16088                &format!("git inspection unavailable: {error}"),
16089            );
16090            return (snapshot, false);
16091        }
16092    };
16093    snapshot.truncated |= repository.truncated;
16094    if repository.timed_out {
16095        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16096        return (snapshot, false);
16097    }
16098    if !repository.success
16099        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16100    {
16101        let stderr = String::from_utf8_lossy(&repository.stderr);
16102        if !stderr.to_ascii_lowercase().contains("not a git repository") {
16103            append_git_diagnostic(&mut snapshot, stderr.trim());
16104        }
16105        return (snapshot, false);
16106    }
16107    snapshot.is_repository = true;
16108
16109    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16110        return (snapshot, true);
16111    }
16112    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16113        Ok(output) => {
16114            snapshot.truncated |= output.truncated;
16115            if output.timed_out {
16116                append_git_diagnostic(&mut snapshot, "git branch query timed out");
16117            } else if output.success {
16118                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16119                if !branch.is_empty() {
16120                    snapshot.branch = Some(truncate_git_field(
16121                        &branch,
16122                        MAX_REPOSITORY_PATH_BYTES,
16123                        &mut snapshot.truncated,
16124                    ));
16125                }
16126            }
16127        }
16128        Err(error) => append_git_diagnostic(
16129            &mut snapshot,
16130            &format!("git branch query failed: {error}"),
16131        ),
16132    }
16133    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16134        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16135            snapshot.truncated |= output.truncated;
16136            if output.success && !output.timed_out {
16137                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16138                if !head.is_empty() {
16139                    snapshot.branch = Some(format!("detached:{head}"));
16140                }
16141            }
16142        }
16143    }
16144
16145    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16146        return (snapshot, true);
16147    }
16148    match run_git_bounded_with_deadline(
16149        root,
16150        &[
16151            "status",
16152            "--porcelain=v1",
16153            "-z",
16154            "--renames",
16155            "--untracked-files=normal",
16156            "--",
16157            ".",
16158        ],
16159        GIT_OUTPUT_MAX_BYTES,
16160        deadline,
16161    )
16162    .await
16163    {
16164        Ok(output) => {
16165            snapshot.truncated |= output.truncated;
16166            if output.timed_out {
16167                append_git_diagnostic(&mut snapshot, "git status timed out");
16168            } else if output.success {
16169                parse_git_status(&output.stdout, &mut snapshot);
16170            } else {
16171                append_git_diagnostic(
16172                    &mut snapshot,
16173                    String::from_utf8_lossy(&output.stderr).trim(),
16174                );
16175            }
16176        }
16177        Err(error) => append_git_diagnostic(
16178            &mut snapshot,
16179            &format!("git status failed: {error}"),
16180        ),
16181    }
16182
16183    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16184        return (snapshot, true);
16185    }
16186    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16187    match run_git_bounded_with_deadline(
16188        root,
16189        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16190        16 * 1_024,
16191        deadline,
16192    )
16193    .await
16194    {
16195        Ok(output) => {
16196            snapshot.truncated |= output.truncated;
16197            if output.timed_out {
16198                append_git_diagnostic(&mut snapshot, "git log timed out");
16199            } else if output.success {
16200                parse_git_commits(&output.stdout, &mut snapshot);
16201            } else {
16202                let stderr = String::from_utf8_lossy(&output.stderr);
16203                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16204                    append_git_diagnostic(&mut snapshot, stderr.trim());
16205                }
16206            }
16207        }
16208        Err(error) => append_git_diagnostic(
16209            &mut snapshot,
16210            &format!("git log failed: {error}"),
16211        ),
16212    }
16213
16214    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16215        return (snapshot, true);
16216    }
16217    match list_git_worktrees_with_deadline(root, deadline).await {
16218        Ok(worktrees) => {
16219            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16220        }
16221        Err(error) => append_git_diagnostic(
16222            &mut snapshot,
16223            &format!("git worktree list failed: {}", error.message),
16224        ),
16225    }
16226    (snapshot, false)
16227}
16228
16229fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16230    let mut remaining = output;
16231    while !remaining.is_empty() {
16232        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16233            snapshot.truncated = true;
16234            break;
16235        }
16236        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16237            snapshot.truncated = true;
16238            break;
16239        };
16240        let record = &remaining[..record_end];
16241        remaining = &remaining[record_end + 1..];
16242        let has_previous_path = record
16243            .get(..2)
16244            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16245        let previous_path_bytes = if has_previous_path {
16246            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16247                snapshot.truncated = true;
16248                break;
16249            };
16250            let previous = &remaining[..previous_end];
16251            remaining = &remaining[previous_end + 1..];
16252            Some(previous)
16253        } else {
16254            None
16255        };
16256        if record.len() < 4
16257            || record[2] != b' '
16258            || !is_git_status_code(record[0])
16259            || !is_git_status_code(record[1])
16260        {
16261            snapshot.truncated = true;
16262            continue;
16263        }
16264        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16265            snapshot.truncated = true;
16266            continue;
16267        };
16268        let previous_path = match previous_path_bytes {
16269            Some(previous) => {
16270                let Some(previous) = windows_repository_path_from_bytes(previous) else {
16271                    snapshot.truncated = true;
16272                    continue;
16273                };
16274                Some(previous)
16275            }
16276            None => None,
16277        };
16278        snapshot.status.push(GitStatusEntry {
16279            index_status: (record[0] as char).to_string(),
16280            worktree_status: (record[1] as char).to_string(),
16281            path,
16282            previous_path,
16283        });
16284    }
16285}
16286
16287fn is_git_status_code(byte: u8) -> bool {
16288    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16289}
16290
16291fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16292    for line in String::from_utf8_lossy(output).lines() {
16293        let Some((id, summary)) = line.split_once('\u{1f}') else {
16294            continue;
16295        };
16296        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16297            snapshot.truncated = true;
16298            break;
16299        }
16300        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16301        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16302        snapshot.recent_commits.push(GitCommitSummary { id, summary });
16303    }
16304}
16305
16306fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16307    if value.len() <= max_bytes {
16308        return value.to_owned();
16309    }
16310    *truncated = true;
16311    let mut boundary = max_bytes;
16312    while boundary > 0 && !value.is_char_boundary(boundary) {
16313        boundary -= 1;
16314    }
16315    value[..boundary].to_owned()
16316}
16317
16318fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16319    if message.is_empty() {
16320        return;
16321    }
16322    let normalized = message
16323        .chars()
16324        .map(|character| if character.is_control() { ' ' } else { character })
16325        .collect::<String>();
16326    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16327    if !diagnostic.is_empty() {
16328        diagnostic.push_str("; ");
16329    }
16330    diagnostic.push_str(normalized.trim());
16331    snapshot.diagnostic = Some(truncate_git_field(
16332        &diagnostic,
16333        GIT_DIAGNOSTIC_MAX_BYTES,
16334        &mut snapshot.truncated,
16335    ));
16336}
16337
16338async fn run_git_bounded(
16339    root: &str,
16340    arguments: &[&str],
16341    output_limit: usize,
16342) -> io::Result<GitCommandOutput> {
16343    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16344}
16345
16346async fn read_git_history_bounded(
16347    root: &str,
16348    path: Option<&RepositoryPath>,
16349    before: Option<&GitObjectId>,
16350    limit: u16,
16351) -> Result<GitHistoryPage, NodeFailure> {
16352    let path = path
16353        .map(|path| {
16354            path.as_utf8().ok_or_else(|| {
16355                failure(
16356                    NodeFailureCode::InvalidRepositoryPath,
16357                    "git history path must use UTF-8",
16358                )
16359            })
16360        })
16361        .transpose()?
16362        .unwrap_or(".");
16363    let count = (usize::from(limit) + 1).to_string();
16364    let start = before
16365        .map(|revision| format!("{}^", revision.as_str()))
16366        .unwrap_or_else(|| "HEAD".to_owned());
16367    let arguments = [
16368        "--no-pager",
16369        "log",
16370        "--no-decorate",
16371        "--date=iso-strict",
16372        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16373        "-n",
16374        count.as_str(),
16375        start.as_str(),
16376        "--",
16377        path,
16378    ];
16379    let output = run_git_bounded(root, &arguments, 256 * 1_024)
16380        .await
16381        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16382    if output.timed_out {
16383        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16384    }
16385    if !output.success {
16386        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16387    }
16388    let mut commits = parse_git_history(&output.stdout);
16389    let has_more = commits.len() > usize::from(limit) || output.truncated;
16390    commits.truncate(usize::from(limit));
16391    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16392    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16393}
16394
16395fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16396    String::from_utf8_lossy(output)
16397        .split('\u{1e}')
16398        .filter_map(|record| {
16399            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16400            if fields.len() != 9 {
16401                return None;
16402            }
16403            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16404            let parents = if fields[1].is_empty() {
16405                Vec::new()
16406            } else {
16407                fields[1]
16408                    .split(' ')
16409                    .map(|value| GitObjectId::new(value.to_owned()))
16410                    .collect::<Result<Vec<_>, _>>()
16411                    .ok()?
16412            };
16413            Some(GitCommitDetails {
16414                id,
16415                parents,
16416                subject: bounded_git_text(fields[2], 512),
16417                author_name: bounded_git_text(fields[3], 256),
16418                author_email: bounded_git_text(fields[4], 320),
16419                authored_at: bounded_git_text(fields[5], 64),
16420                committer_name: bounded_git_text(fields[6], 256),
16421                committer_email: bounded_git_text(fields[7], 320),
16422                committed_at: bounded_git_text(fields[8], 64),
16423                signature_status: GitSignatureStatus::NoSignature,
16424                signer: None,
16425            })
16426        })
16427        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16428        .collect()
16429}
16430
16431fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16432    let normalized = value
16433        .chars()
16434        .map(|character| if character.is_control() { ' ' } else { character })
16435        .collect::<String>();
16436    if normalized.len() <= max_bytes {
16437        return normalized;
16438    }
16439    let mut boundary = max_bytes;
16440    while boundary > 0 && !normalized.is_char_boundary(boundary) {
16441        boundary -= 1;
16442    }
16443    normalized[..boundary].to_owned()
16444}
16445
16446async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16447    let path = request.path.as_ref().map(|path| {
16448        path.as_utf8()
16449            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16450    }).transpose()?;
16451    let mut arguments = match &request.mode {
16452        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16453        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16454        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16455    };
16456    if let GitDiffMode::Commit { revision } = &request.mode {
16457        arguments.push(revision.as_str());
16458    }
16459    arguments.push("--");
16460    if let Some(path) = path {
16461        arguments.push(path);
16462    }
16463    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16464        .await
16465        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16466    if output.timed_out {
16467        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16468    }
16469    if !output.success {
16470        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16471    }
16472    Ok(GitDiff {
16473        mode: request.mode,
16474        path: request.path,
16475        text: String::from_utf8_lossy(&output.stdout).into_owned(),
16476        truncated: output.truncated,
16477    })
16478}
16479
16480async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16481    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16482    ResponseEnvelope { request_id: envelope.request_id, result }
16483}
16484
16485async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16486    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16487        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16488    }
16489    let read_only = matches!(
16490        &request,
16491        NodeRequest::Snapshot
16492            | NodeRequest::Resync { .. }
16493            | NodeRequest::BrowseHostDirectories { .. }
16494            | NodeRequest::InspectWorkspace { .. }
16495            | NodeRequest::ReadWorkspaceFile { .. }
16496            | NodeRequest::ReadGitHistory { .. }
16497            | NodeRequest::ReadGitDiff { .. }
16498            | NodeRequest::CatalogNativeSessions { .. }
16499            | NodeRequest::PageNativeSessions { .. }
16500            | NodeRequest::PreviewNativeSession { .. }
16501            | NodeRequest::PreviewSessionRecord { .. }
16502    );
16503    let _mutation_guard = if read_only {
16504        None
16505    } else {
16506        let guard = shared.mutation_gate.lock().await;
16507        if shared.shutdown.load(Ordering::Acquire) {
16508            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16509        }
16510        Some(guard)
16511    };
16512    match request {
16513        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16514            event_sequence: shared.current_sequence(),
16515            controller: shared.controller_state(),
16516            snapshot: shared.snapshot(),
16517        }),
16518        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16519        NodeRequest::ArmHarnessMcpReservation {
16520            reservation_id,
16521            activation_digest,
16522            spawn_spec,
16523            launch,
16524            expires_at_unix_ms,
16525        } => {
16526            shared.require_controller(connection_id, role)?;
16527            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16528                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16529            })?;
16530            let expires_at_unix_ms = registry
16531                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16532                .await
16533                .map_err(harness_mcp_failure)?;
16534            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16535        }
16536        NodeRequest::SpawnSpecWithHarnessMcp {
16537            reservation_id,
16538            activation_digest,
16539            spec,
16540            deadline_unix_ms,
16541        } => {
16542            shared.require_controller(connection_id, role)?;
16543            let receipt = shared
16544                .spawn_from_spec_with_harness_mcp(
16545                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16546                )
16547                .await?;
16548            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16549        }
16550        NodeRequest::ActivateHarnessMcpReservation {
16551            reservation_id,
16552            activation_digest,
16553            record_id,
16554            session,
16555        } => {
16556            shared.require_controller(connection_id, role)?;
16557            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16558                current.instance_id == session.session.instance_id
16559                    && current.generation == session.session.generation
16560            }).and_then(|current| current.process_id).ok_or_else(|| {
16561                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16562            })?;
16563            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16564                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16565            })?.activate(
16566                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16567            ).map_err(harness_mcp_failure)?;
16568            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16569        }
16570        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16571            shared.require_controller(connection_id, role)?;
16572            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16573                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16574            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16575            if let Some(instance_id) = instance_id {
16576                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16577            }
16578            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16579        }
16580        NodeRequest::PutHarnessMcpReplyChunk {
16581            reservation_id, activation_digest, record_id, session, call_id,
16582            offset, final_chunk, chunk_hex,
16583        } => {
16584            shared.require_controller(connection_id, role)?;
16585            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16586                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16587            })?.put_reply_chunk(
16588                &reservation_id, &activation_digest, &record_id, &session, &call_id,
16589                offset, final_chunk, &chunk_hex,
16590            ).map_err(harness_mcp_failure)?;
16591            Ok(NodeResponse::ReplyChunkAccepted {
16592                reservation_id, activation_digest, record_id, session, call_id,
16593                next_offset, completed,
16594            })
16595        }
16596        NodeRequest::RejectHarnessMcpCall {
16597            reservation_id, activation_digest, record_id, session, call_id, reason,
16598        } => {
16599            shared.require_controller(connection_id, role)?;
16600            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16601                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16602            })?.reject_call(
16603                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16604            ).map_err(harness_mcp_failure)?;
16605            Ok(NodeResponse::CallRejected {
16606                reservation_id, activation_digest, record_id, session, call_id,
16607            })
16608        }
16609        NodeRequest::BeginDeliveryStage { manifest } => {
16610            shared.require_controller(connection_id, role)?;
16611            shared.begin_delivery_stage(manifest)
16612        }
16613        NodeRequest::PutDeliveryBlobChunk {
16614            stage_id,
16615            blob_digest,
16616            offset,
16617            chunk_hex,
16618        } => {
16619            shared.require_controller(connection_id, role)?;
16620            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16621        }
16622        NodeRequest::CommitDeliveryStage { stage_id } => {
16623            shared.require_controller(connection_id, role)?;
16624            let receipt = shared.commit_delivery_stage(stage_id)?;
16625            Ok(NodeResponse::DeliveryCommitted { receipt })
16626        }
16627        NodeRequest::AbortDeliveryStage { stage_id } => {
16628            shared.require_controller(connection_id, role)?;
16629            shared.abort_delivery_stage(&stage_id)?;
16630            Ok(NodeResponse::DeliveryStageAborted { stage_id })
16631        }
16632        NodeRequest::BrowseHostDirectories { directory, after } => {
16633            let listing = shared.browse_host_directories(directory, after).await?;
16634            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16635        }
16636        NodeRequest::InspectWorkspace { workspace_id } => {
16637            let inspection = shared.inspect_workspace(workspace_id).await?;
16638            Ok(NodeResponse::WorkspaceInspected { inspection })
16639        }
16640        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16641            let file = shared.read_workspace_file(workspace_id, path).await?;
16642            Ok(NodeResponse::WorkspaceFileRead { file })
16643        }
16644        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16645            shared.require_controller(connection_id, role)?;
16646            let file = shared
16647                .write_workspace_file(workspace_id, path, expected_revision, text)
16648                .await?;
16649            Ok(NodeResponse::WorkspaceFileWritten { file })
16650        }
16651        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16652            shared.require_controller(connection_id, role)?;
16653            let file = shared.create_workspace_file(workspace_id, path).await?;
16654            Ok(NodeResponse::WorkspaceFileCreated { file })
16655        }
16656        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16657            shared.require_controller(connection_id, role)?;
16658            let entry = shared
16659                .create_workspace_directory(workspace_id.clone(), path)
16660                .await?;
16661            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16662        }
16663        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16664            if role != ClientRole::Operator {
16665                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16666            }
16667            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16668                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16669            }
16670            let page = shared
16671                .read_git_history(workspace_id.clone(), path, before, limit)
16672                .await?;
16673            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16674        }
16675        NodeRequest::ReadGitDiff { workspace_id, request } => {
16676            if role != ClientRole::Operator {
16677                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16678            }
16679            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16680            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16681        }
16682        NodeRequest::CatalogNativeSessions { route, limit } => {
16683            if role != ClientRole::Operator {
16684                return Err(failure(
16685                    NodeFailureCode::ObserverReadOnly,
16686                    "native session catalog requires an operator connection",
16687                ));
16688            }
16689            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16690                return Err(failure(
16691                    NodeFailureCode::InvalidRequest,
16692                    "native session catalog limit is invalid",
16693                ));
16694            }
16695            let (entries, summary) = shared
16696                .catalog_native_sessions(route.clone(), limit)
16697                .await?;
16698            Ok(NodeResponse::NativeSessionsCataloged {
16699                route,
16700                entries,
16701                summary: Some(summary),
16702            })
16703        }
16704        NodeRequest::PageNativeSessions {
16705            route,
16706            window,
16707            catalog_revision,
16708            recent_cutoff_unix_ms,
16709            after_selection_id,
16710            limit,
16711        } => {
16712            if role != ClientRole::Operator {
16713                return Err(failure(
16714                    NodeFailureCode::ObserverReadOnly,
16715                    "native session catalog paging requires an operator connection",
16716                ));
16717            }
16718            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16719                || after_selection_id
16720                    .as_deref()
16721                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16722            {
16723                return Err(failure(
16724                    NodeFailureCode::InvalidRequest,
16725                    "native session catalog page request is invalid",
16726                ));
16727            }
16728            let page = shared
16729                .page_native_sessions(
16730                    route.clone(),
16731                    window,
16732                    catalog_revision,
16733                    recent_cutoff_unix_ms,
16734                    after_selection_id,
16735                    limit,
16736                )
16737                .await?;
16738            Ok(NodeResponse::NativeSessionsPaged {
16739                route,
16740                page,
16741            })
16742        }
16743        NodeRequest::PreviewNativeSession {
16744            selection,
16745            message_limit,
16746        } => {
16747            if role != ClientRole::Operator {
16748                return Err(failure(
16749                    NodeFailureCode::ObserverReadOnly,
16750                    "native session preview requires an operator connection",
16751                ));
16752            }
16753            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16754                .contains(&message_limit)
16755                || selection.validate().is_err()
16756            {
16757                return Err(failure(
16758                    NodeFailureCode::InvalidRequest,
16759                    "native session preview request is invalid",
16760                ));
16761            }
16762            let preview = shared
16763                .preview_native_session(selection.clone(), message_limit)
16764                .await?;
16765            Ok(NodeResponse::NativeSessionPreviewed {
16766                selection,
16767                preview,
16768            })
16769        }
16770        NodeRequest::IndexNativeSession {
16771            selection,
16772            display_name,
16773        } => {
16774            shared.require_controller(connection_id, role)?;
16775            let record = shared
16776                .index_native_session(selection.clone(), display_name)
16777                .await?;
16778            Ok(NodeResponse::NativeSessionIndexed { selection, record })
16779        }
16780        NodeRequest::PreviewSessionRecord {
16781            record_id,
16782            message_limit,
16783        } => {
16784            if role != ClientRole::Operator {
16785                return Err(failure(
16786                    NodeFailureCode::ObserverReadOnly,
16787                    "native session preview requires an operator connection",
16788                ));
16789            }
16790            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16791                .contains(&message_limit)
16792            {
16793                return Err(failure(
16794                    NodeFailureCode::InvalidRequest,
16795                    "native session preview request is invalid",
16796                ));
16797            }
16798            let record = shared.record(&record_id)?;
16799            let working_directory = shared.workspace_root(&record.workspace_id)?;
16800            if !platform::roots_equal(
16801                &working_directory,
16802                windows_path_text(&record.canonical_root),
16803            ) {
16804                return Err(failure(
16805                    NodeFailureCode::SessionRecordConflict,
16806                    "managed session workspace root changed; refusing to preview another directory",
16807                ));
16808            }
16809            let identity = record.provider_session.clone().ok_or_else(|| {
16810                failure(
16811                    NodeFailureCode::BackendOperationFailed,
16812                    "native session preview is unavailable for this session record",
16813                )
16814            })?;
16815            if identity.key != ProviderSessionKey::SessionId {
16816                return Err(failure(
16817                    NodeFailureCode::BackendOperationFailed,
16818                    "native session preview is unavailable for this session identity",
16819                ));
16820            }
16821            let preview = shared
16822                .preview_native_session_by_session_id(
16823                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16824                )
16825                .await?;
16826            if preview.session_id != identity.id {
16827                return Err(failure(
16828                    NodeFailureCode::SessionRecordConflict,
16829                    "native session preview identity changed while loading",
16830                ));
16831            }
16832            shared.revalidate_session_record_preview(&record, &identity)?;
16833            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16834            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16835                record_id: record_id.clone(),
16836                summary: SessionHistorySummaryV1::from(&preview),
16837            });
16838            Ok(NodeResponse::SessionRecordPreviewed {
16839                record_id,
16840                preview,
16841            })
16842        }
16843        NodeRequest::AcquireController { lease_ms } => {
16844            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16845            Ok(NodeResponse::Controller { controller: Some(controller) })
16846        }
16847        NodeRequest::ReleaseController => {
16848            shared.release_controller(connection_id);
16849            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16850        }
16851        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16852            shared.require_controller(connection_id, role)?;
16853            let root = require_windows_path(root)?;
16854            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16855            let workspace = shared.register_workspace(workspace_id, root).await?;
16856            Ok(NodeResponse::WorkspaceRegistered { workspace })
16857        }
16858        NodeRequest::CreateStandaloneWorkspace {
16859            workspace_id,
16860            root,
16861            initial_branch,
16862        } => {
16863            shared.require_controller(connection_id, role)?;
16864            let root = require_windows_path(root)?;
16865            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16866            let workspace = shared
16867                .create_standalone_workspace(workspace_id, root, initial_branch)
16868                .await?;
16869            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16870        }
16871        NodeRequest::UnregisterWorkspace { workspace_id } => {
16872            shared.require_controller(connection_id, role)?;
16873            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16874            shared.unregister_workspace(&workspace_id)?;
16875            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16876        }
16877        NodeRequest::CreateWorktree {
16878            source_workspace_id,
16879            workspace_id,
16880            target_root,
16881            branch,
16882            base,
16883        } => {
16884            shared.require_controller(connection_id, role)?;
16885            let target_root = require_windows_path(target_root)?;
16886            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16887            let (worktree, workspace) = shared
16888                .create_worktree(
16889                    source_workspace_id,
16890                    workspace_id,
16891                    target_root,
16892                    branch,
16893                    base,
16894                )
16895                .await?;
16896            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16897        }
16898        NodeRequest::RemoveWorktree {
16899            source_workspace_id,
16900            target_root,
16901        } => {
16902            shared.require_controller(connection_id, role)?;
16903            let native_target_root = require_windows_path(target_root.clone())?;
16904            shared.reject_managed_reservation(None, Some(&native_target_root))?;
16905            let workspace_id = shared
16906                .remove_worktree(source_workspace_id, native_target_root)
16907                .await?;
16908            Ok(NodeResponse::WorktreeRemoved {
16909                target_root,
16910                workspace_id,
16911            })
16912        }
16913        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16914            shared.require_controller(connection_id, role)?;
16915            if !terminal_size.is_valid() {
16916                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16917            }
16918            if let Some(prompt) = initial_prompt.as_deref() {
16919                validate_node_text("spawn initial prompt", prompt)?;
16920            }
16921            match shared
16922                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16923                .await
16924            {
16925                Ok(session) => {
16926                    shared.mark_spawn_provisional(session.clone());
16927                    Ok(NodeResponse::SpawnAccepted { session })
16928                }
16929                Err(err) => {
16930                    tracing::warn!(
16931                        %provider,
16932                        %workspace_id,
16933                        ?mode,
16934                        code = ?err.code,
16935                        message = %err.message,
16936                        "session spawn refused"
16937                    );
16938                    Err(err)
16939                }
16940            }
16941        }
16942        NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16943            shared.require_controller(connection_id, role)?;
16944            if !terminal_size.is_valid() {
16945                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16946            }
16947            if let Some(prompt) = initial_prompt.as_deref() {
16948                validate_node_text("spawn initial prompt", prompt)?;
16949            }
16950            let plan = backend_paths::plan_launch_options(
16951                provider.as_str(),
16952                mode,
16953                options.model.is_some(),
16954                options.effort.is_some(),
16955            )
16956            .map_err(|r| {
16957                tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16958                failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16959            })?;
16960            let selection = spawn_selection(&options)?;
16961            let pty_selection = match plan {
16962                backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16963                _ => None,
16964            };
16965            let spawned = SPAWN_SESSION_OPTIONS
16966                .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16967                .await;
16968            match spawned {
16969                Ok(session) => {
16970                    if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16971                        shared
16972                            .pending_acp_options
16973                            .lock()
16974                            .unwrap_or_else(|p| p.into_inner())
16975                            .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16976                    }
16977                    shared.mark_spawn_provisional(session.clone());
16978                    tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
16979                    Ok(NodeResponse::SpawnAccepted { session })
16980                }
16981                Err(err) => {
16982                    tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
16983                    Err(err)
16984                }
16985            }
16986        }
16987        NodeRequest::SpawnSpec { spec } => {
16988            shared.require_controller(connection_id, role)?;
16989            let receipt = shared.spawn_from_spec(spec).await?;
16990            Ok(NodeResponse::SpawnSpecAccepted { receipt })
16991        }
16992        NodeRequest::SpawnManagedWorktree { request } => {
16993            shared.require_controller(connection_id, role)?;
16994            let receipt = shared.spawn_managed_worktree(request).await?;
16995            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16996        }
16997        NodeRequest::SpawnManagedWorktreeV2 { request } => {
16998            shared.require_controller(connection_id, role)?;
16999            let receipt = shared.spawn_managed_worktree_v2(request).await?;
17000            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17001        }
17002        NodeRequest::CleanupManagedWorktree { lease_id } => {
17003            shared.require_controller(connection_id, role)?;
17004            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17005            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17006        }
17007        NodeRequest::DiscoverHistory { session, limit } => {
17008            controlled_session(shared, connection_id, role, &session)?;
17009            let candidates = shared.discover_history(&session, limit).await?;
17010            Ok(NodeResponse::HistoryDiscovered {
17011                session,
17012                candidates,
17013            })
17014        }
17015        NodeRequest::LoadHistory {
17016            session,
17017            candidate_id,
17018        } => {
17019            controlled_session(shared, connection_id, role, &session)?;
17020            let loaded = shared
17021                .load_history(&session, candidate_id)
17022                .await?;
17023            Ok(NodeResponse::HistoryLoaded {
17024                session,
17025                session_id: loaded.session_id,
17026                message_count: loaded.message_count,
17027                completed_turn_count: loaded.completed_turn_count,
17028            })
17029        }
17030        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17031            shared.require_controller(connection_id, role)?;
17032            let context = shared
17033                .export_context_pack_for_session_record(&record_id, &session, false)
17034                .await?;
17035            Ok(NodeResponse::ContextPackForSessionRecordExported {
17036                record_id,
17037                session,
17038                context,
17039            })
17040        }
17041        NodeRequest::ExportContextPack { session } => {
17042            controlled_session(shared, connection_id, role, &session)?;
17043            let context = shared.export_context_pack(&session).await?;
17044            Ok(NodeResponse::ContextPackExported { context })
17045        }
17046        NodeRequest::ForgetContextPack { context_id } => {
17047            shared.require_controller(connection_id, role)?;
17048            shared.forget_context_pack(&context_id)?;
17049            Ok(NodeResponse::ContextPackForgotten { context_id })
17050        }
17051        NodeRequest::ResolveDurableContextPack { context_id } => {
17052            // Read-only, idempotent, no controller lease required: this lets
17053            // a caller preflight "is this durably exported pack still here"
17054            // without holding the Node's mutation controller.
17055            let context = shared
17056                .context_catalog
17057                .read()
17058                .unwrap_or_else(|poisoned| poisoned.into_inner())
17059                .get(&context_id)
17060                .map(|pack| pack.receipt().clone())
17061                .ok_or_else(|| {
17062                    failure(
17063                        NodeFailureCode::UnknownContextPack,
17064                        "context pack is not present",
17065                    )
17066                })?;
17067            Ok(NodeResponse::DurableContextPackResolved { context })
17068        }
17069        NodeRequest::ReadContextPack { digest } => {
17070            // Read-only fetch of the pack's own bytes, keyed by content digest
17071            // rather than the caller's `SpawnContextId`: same no-controller-lease
17072            // rationale as `ResolveDurableContextPack` above, one step further
17073            // down the same catalog.
17074            let (id, byte_len, bytes) = shared
17075                .context_catalog
17076                .read()
17077                .unwrap_or_else(|poisoned| poisoned.into_inner())
17078                .get_by_digest(&digest)
17079                .map(|pack| {
17080                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17081                })
17082                .ok_or_else(|| {
17083                    failure(
17084                        NodeFailureCode::UnknownContextPack,
17085                        &format!("context pack {digest} not on this node"),
17086                    )
17087                })?;
17088            tracing::debug!(
17089                digest = digest.as_str(),
17090                byte_len,
17091                "read context pack bytes",
17092            );
17093            Ok(NodeResponse::ContextPackBytesRead {
17094                pack: ContextPackBytesRead {
17095                    digest,
17096                    id,
17097                    byte_len,
17098                    bytes,
17099                },
17100            })
17101        }
17102        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17103            let provider = controlled_session(shared, connection_id, role, &session)?;
17104            if let Some(prompt) = initial_prompt.as_deref() {
17105                validate_node_text("resume initial prompt", prompt)?;
17106            }
17107            let runtime_requirement = if initial_prompt.is_some() {
17108                ProviderRuntimeRequirement::ResumeWithPrompt
17109            } else {
17110                ProviderRuntimeRequirement::Resume
17111            };
17112            shared.require_session_runtime_policy(&session, runtime_requirement)?;
17113            let runtime_policy = shared
17114                .admit_provider_runtime(&provider, runtime_requirement)
17115                .await?;
17116            let working_directory = shared.bound_workspace_root(&session)?;
17117            let request = ResumeLaunchRequest {
17118                working_directory,
17119                terminal_size,
17120                initial_prompt,
17121            };
17122            request
17123                .validate()
17124                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17125            let command = shared.prepare_command(ControlCommand::Resume {
17126                instance_id: session.session.instance_id,
17127                target: ResumeTarget::CurrentProvider,
17128                runtime_policy,
17129                request,
17130            });
17131            shared.arm_resume(&session, command.id, runtime_policy)?;
17132            let dispatch = shared.dispatch_envelope(command);
17133            if let Err(error) = dispatch {
17134                shared.clear_armed_resume(&session);
17135                return Err(error);
17136            }
17137            Ok(NodeResponse::Accepted)
17138        }
17139        NodeRequest::RenameSessionRecord {
17140            record_id,
17141            display_name,
17142        } => {
17143            shared.require_controller(connection_id, role)?;
17144            let record = shared.rename_session_record(&record_id, display_name)?;
17145            Ok(NodeResponse::SessionRecordUpdated { record })
17146        }
17147        NodeRequest::SetSessionTask {
17148            record_id,
17149            expected_revision,
17150            target,
17151        } => {
17152            shared.require_controller(connection_id, role)?;
17153            let record = shared.set_session_task(&record_id, expected_revision, target)?;
17154            Ok(NodeResponse::SessionRecordUpdated { record })
17155        }
17156        NodeRequest::IndexProviderSession {
17157            workspace_id,
17158            provider,
17159            identity,
17160            display_name,
17161        } => {
17162            shared.require_controller(connection_id, role)?;
17163            let record = shared.index_provider_session(
17164                workspace_id,
17165                provider,
17166                identity,
17167                display_name,
17168            )?;
17169            Ok(NodeResponse::ProviderSessionIndexed { record })
17170        }
17171        NodeRequest::ResumeSessionRecord {
17172            record_id,
17173            terminal_size,
17174            initial_prompt,
17175        } => {
17176            shared.require_controller(connection_id, role)?;
17177            if let Some(prompt) = initial_prompt.as_deref() {
17178                validate_node_text("managed resume initial prompt", prompt)?;
17179            }
17180            let (record, session) = shared
17181                .resume_session_record(
17182                    &record_id,
17183                    terminal_size,
17184                    initial_prompt,
17185                )
17186                .await?;
17187            Ok(NodeResponse::SessionRecordResumed { record, session })
17188        }
17189        NodeRequest::ForgetSessionRecord { record_id } => {
17190            shared.require_controller(connection_id, role)?;
17191            shared.forget_session_record(&record_id).await?;
17192            Ok(NodeResponse::SessionRecordForgotten { record_id })
17193        }
17194        NodeRequest::Prompt { session, text } => {
17195            let agent_id = controlled_session(shared, connection_id, role, &session)?;
17196            validate_node_text("prompt", &text)?;
17197            shared.require_session_runtime_policy(
17198                &session,
17199                ProviderRuntimeRequirement::SemanticPrompt,
17200            )?;
17201            let framing = prompt_framing(&agent_id);
17202            shared
17203                .dispatch_input_bounded(
17204                    &session,
17205                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
17206                )
17207                .await?;
17208            Ok(NodeResponse::Accepted)
17209        }
17210        NodeRequest::Paste { session, text } => {
17211            controlled_session(shared, connection_id, role, &session)?;
17212            validate_node_text("paste", &text)?;
17213            shared.require_session_runtime_policy(
17214                &session,
17215                ProviderRuntimeRequirement::SemanticPrompt,
17216            )?;
17217            shared
17218                .dispatch_input_bounded(
17219                    &session,
17220                    InputAction::InsertDraft(PromptPayload {
17221                        text,
17222                        framing: PromptFraming::BracketedPaste,
17223                    }),
17224                )
17225                .await?;
17226            Ok(NodeResponse::Accepted)
17227        }
17228        NodeRequest::Input { session, text } => {
17229            controlled_session(shared, connection_id, role, &session)?;
17230            validate_node_text("terminal input", &text)?;
17231            shared
17232                .dispatch_input_bounded(
17233                    &session,
17234                    InputAction::TerminalText(TerminalText { text }),
17235                )
17236                .await?;
17237            Ok(NodeResponse::Accepted)
17238        }
17239        NodeRequest::TerminalBytes { session, bytes } => {
17240            controlled_session(shared, connection_id, role, &session)?;
17241            shared.sessionize_spawn(session.session.instance_id);
17242            let action = terminal_bytes_action(bytes)?;
17243            shared
17244                .dispatch_input_bounded(&session, action)
17245                .await?;
17246            Ok(NodeResponse::Accepted)
17247        }
17248        NodeRequest::TerminalControl { session, control } => {
17249            controlled_session(shared, connection_id, role, &session)?;
17250            shared
17251                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17252                .await?;
17253            Ok(NodeResponse::Accepted)
17254        }
17255        NodeRequest::Resize { session, size } => {
17256            controlled_session(shared, connection_id, role, &session)?;
17257            shared.sessionize_spawn(session.session.instance_id);
17258            if !size.is_valid() {
17259                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17260            }
17261            shared.dispatch_resize_bounded(&session, size).await?;
17262            Ok(NodeResponse::Accepted)
17263        }
17264        NodeRequest::Interrupt { session } => {
17265            controlled_session(shared, connection_id, role, &session)?;
17266            shared
17267                .dispatch_input_bounded(
17268                    &session,
17269                    InputAction::TerminalControl(TerminalControl::Interrupt),
17270                )
17271                .await?;
17272            Ok(NodeResponse::Accepted)
17273        }
17274        NodeRequest::Stop { session, force } => {
17275            controlled_session(shared, connection_id, role, &session)?;
17276            shared.sessionize_spawn(session.session.instance_id);
17277            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17278            shared.retire_session_grok_leader(session.session.instance_id);
17279            Ok(NodeResponse::Accepted)
17280        }
17281        NodeRequest::Remove { session } => {
17282            controlled_session(shared, connection_id, role, &session)?;
17283            shared.remove_session(&session).await?;
17284            Ok(NodeResponse::Accepted)
17285        }
17286        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17287            shared.require_controller(connection_id, role)?;
17288            let snapshot = shared.require_acp_session(&session)?;
17289            let interaction = snapshot
17290                .provider
17291                .interactions
17292                .iter()
17293                .find(|interaction| {
17294                    interaction_correlation(
17295                        session.session.instance_id,
17296                        session.session.generation,
17297                        interaction.id.0,
17298                    ) == correlation_id
17299                })
17300                .ok_or_else(|| failure(
17301                    NodeFailureCode::InvalidRequest,
17302                    "unknown interaction correlation",
17303                ))?;
17304            if interaction.status != ProviderInteractionStatus::Pending {
17305                return Err(failure(
17306                    NodeFailureCode::BackendOperationFailed,
17307                    "interaction has already been resolved",
17308                ));
17309            }
17310            response
17311                .validate_for(interaction.interaction_kind)
17312                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17313            let interaction_id = interaction.id;
17314            let response_kind = response.kind();
17315            let response_outcome = response.outcome();
17316            shared.dispatch(ControlCommand::ResolveInteraction {
17317                instance_id: session.session.instance_id,
17318                generation: session.session.generation,
17319                interaction_id,
17320                response,
17321            })?;
17322            // The one line that lets an operator compare what THIS node
17323            // recorded against what the provider itself reports doing --
17324            // measured live on a kimi run where our side resolved `true`
17325            // while the agent reported "rejected" and neither layer had a
17326            // line to line up against the other.
17327            tracing::info!(
17328                node_id = %shared.node_id,
17329                session = ?session.session,
17330                correlation_id = %correlation_id,
17331                interaction_id = interaction_id.0,
17332                response_kind = ?response_kind,
17333                outcome = ?response_outcome,
17334                "interaction resolved",
17335            );
17336            Ok(NodeResponse::Accepted)
17337        }
17338        NodeRequest::SetSessionMode { session, mode_id } => {
17339            shared.require_controller(connection_id, role)?;
17340            shared.require_acp_session(&session)?;
17341            shared.dispatch(ControlCommand::SetSessionMode {
17342                instance_id: session.session.instance_id,
17343                mode_id,
17344            })?;
17345            Ok(NodeResponse::Accepted)
17346        }
17347        NodeRequest::SetSessionConfigOption {
17348            session,
17349            option_id,
17350            value_json,
17351        } => {
17352            shared.require_controller(connection_id, role)?;
17353            shared.require_acp_session(&session)?;
17354            shared.dispatch(ControlCommand::SetSessionConfigOption {
17355                instance_id: session.session.instance_id,
17356                option_id,
17357                value_json,
17358            })?;
17359            Ok(NodeResponse::Accepted)
17360        }
17361        NodeRequest::SetSessionModel { session, model_id } => {
17362            shared.require_controller(connection_id, role)?;
17363            shared.require_acp_session(&session)?;
17364            shared.dispatch(ControlCommand::SetSessionModel {
17365                instance_id: session.session.instance_id,
17366                model_id,
17367            })?;
17368            Ok(NodeResponse::Accepted)
17369        }
17370        NodeRequest::Shutdown => {
17371            shared.require_controller(connection_id, role)?;
17372            shared.begin_shutdown_locked().await?;
17373            Ok(NodeResponse::ShuttingDown)
17374        }
17375    }
17376}
17377
17378fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17379    shared.require_controller(connection_id, role)?;
17380    shared.validate_address(session)
17381}
17382
17383/// Selects the sole history candidate matching `provider_session_id` when
17384/// the managed record carries a verified identity, or the sole candidate in
17385/// the discovered set at all when it does not (see
17386/// `export_context_pack_for_session_record_inner`'s identity match). Either
17387/// way, more than one match refuses instead of guessing which one is exact.
17388fn select_history_candidate(
17389    candidates: &[HistoryCandidateSummary],
17390    provider_session_id: Option<&str>,
17391) -> Result<String, NodeFailure> {
17392    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17393        Some(id) => candidate.session_id_hint == id,
17394        None => true,
17395    });
17396    let candidate = matches.next().ok_or_else(|| {
17397        failure(
17398            NodeFailureCode::SessionRecordConflict,
17399            if provider_session_id.is_some() {
17400                "no history candidate matches the managed provider session"
17401            } else {
17402                "no history candidate exists for the managed session"
17403            },
17404        )
17405    })?;
17406    if matches.next().is_some() {
17407        return Err(failure(
17408            NodeFailureCode::SessionRecordConflict,
17409            if provider_session_id.is_some() {
17410                "history identity is ambiguous for the managed provider session"
17411            } else {
17412                "history is ambiguous without a verified provider session identity"
17413            },
17414        ));
17415    }
17416    Ok(candidate.id.clone())
17417}
17418
17419fn resolved_spawn_profile_summary(
17420    profile: &SpawnProfileDefaults,
17421    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17422) -> Option<SpawnProfileSummary> {
17423    let environment_profile = match profile.environment_profile_id.as_ref() {
17424        None => None,
17425        Some(profile_id) => {
17426            let binding = environment_profiles.get(profile_id)?;
17427            if binding.provider != profile.provider
17428                || binding.native_profile_id(profile.mode).is_none()
17429            {
17430                return None;
17431            }
17432            Some(ResolvedEnvironmentProfileReceipt {
17433                profile_id: binding.id.clone(),
17434                profile_revision: binding.revision.clone(),
17435                network_allowlist: None,
17436                browser_profile_id: None,
17437            })
17438        }
17439    };
17440    Some(SpawnProfileSummary {
17441        id: profile.profile_id.clone(),
17442        revision: profile.revision.clone(),
17443        environment_profile,
17444    })
17445}
17446
17447/// True if `current` is an acceptable version of `expected` at export
17448/// revalidation/commit time.
17449///
17450/// When `allow_clean_detachment` is `false` (the on-demand,
17451/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
17452/// be byte-identical to `expected` and still actively bound to `session` —
17453/// the original, unchanged invariant.
17454///
17455/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
17456/// ALSO be the exact Live -> Dormant transition that the same clean exit this
17457/// export reacts to produces on its own: `state` Live -> Dormant,
17458/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
17459/// advancing. Every other field — including `provider_session`, `context`,
17460/// `bundle`, `task_binding` — must still match exactly. A record that changed
17461/// in any other way (rebind, a different active session, an identity
17462/// conflict, capacity eviction) is rejected either way; this widens nothing
17463/// about which sessions are eligible, only how the record's own expected
17464/// detachment is tolerated mid-export.
17465fn session_record_export_target_matches(
17466    expected: &ManagedSessionRecord,
17467    current: &ManagedSessionRecord,
17468    session: &SessionAddress,
17469    allow_clean_detachment: bool,
17470) -> bool {
17471    if current == expected && current.active_session.as_ref() == Some(session) {
17472        return current.state == ManagedSessionState::Live;
17473    }
17474    allow_clean_detachment
17475        && current.state == ManagedSessionState::Dormant
17476        && current.active_session.is_none()
17477        && current.record_id == expected.record_id
17478        && current.display_name == expected.display_name
17479        && current.provider == expected.provider
17480        && current.mode == expected.mode
17481        && current.workspace_id == expected.workspace_id
17482        && current.canonical_root == expected.canonical_root
17483        && current.provider_session == expected.provider_session
17484        && current.environment_profile == expected.environment_profile
17485        && current.bundle == expected.bundle
17486        && current.context_id == expected.context_id
17487        && current.context == expected.context
17488        && current.exported_context == expected.exported_context
17489        && current.task_binding == expected.task_binding
17490        && current.created_at_unix_ms == expected.created_at_unix_ms
17491        && current.last_error == expected.last_error
17492}
17493
17494fn session_record_context_export_binding_is_exact(
17495    expected: &ManagedSessionRecord,
17496    current: &ManagedSessionRecord,
17497    identity: Option<&ProviderSessionIdentity>,
17498    session: &SessionAddress,
17499    binding: &SessionBinding,
17500    provider: &AgentId,
17501    current_root: &str,
17502    allow_clean_detachment: bool,
17503) -> bool {
17504    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17505        && current.provider_session.as_ref() == identity
17506        && current.workspace_id == session.workspace_id
17507        && provider == &current.provider
17508        && binding.workspace_id == session.workspace_id
17509        && binding.generation == session.session.generation
17510        && binding.record_id.as_ref() == Some(&current.record_id)
17511        && platform::roots_equal(
17512            current_root,
17513            windows_path_text(&current.canonical_root),
17514        )
17515}
17516
17517fn session_record_context_export_source_is_usable(
17518    record: &ManagedSessionRecord,
17519    status: &SessionStatus,
17520    allow_clean_detachment: bool,
17521) -> bool {
17522    (record.state == ManagedSessionState::Live
17523        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17524        && context_pack_source_status_is_usable(status)
17525}
17526
17527fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17528    PromptFraming::BracketedPaste
17529}
17530
17531fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17532    if text.len() > MAX_NODE_TEXT_BYTES {
17533        return Err(failure(
17534            NodeFailureCode::InvalidRequest,
17535            &format!(
17536                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17537                text.len(),
17538            ),
17539        ));
17540    }
17541    Ok(())
17542}
17543
17544fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17545    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17546        return Err(failure(
17547            NodeFailureCode::InvalidRequest,
17548            &format!(
17549                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17550                bytes.len(),
17551            ),
17552        ));
17553    }
17554    Ok(())
17555}
17556
17557fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17558    validate_terminal_bytes(&bytes)?;
17559    Ok(InputAction::TerminalBytes(bytes))
17560}
17561
17562fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17563    if root.is_empty()
17564        || root.len() > MAX_WORKSPACE_ROOT_BYTES
17565        || root.chars().any(char::is_control)
17566    {
17567        return Err(failure(
17568            NodeFailureCode::InvalidWorkspaceRoot,
17569            &format!(
17570                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17571            ),
17572        ));
17573    }
17574    Ok(())
17575}
17576
17577fn canonical_standalone_workspace_candidate(
17578    workspace_id: &WorkspaceId,
17579    root: &str,
17580) -> Result<String, NodeServerError> {
17581    let path = Path::new(root);
17582    match std::fs::symlink_metadata(path) {
17583        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17584            .map(|workspace| workspace.canonical_root),
17585        Err(error) if error.kind() == io::ErrorKind::NotFound => {
17586            if !path.is_absolute() {
17587                return Err(NodeServerError::InvalidWorkspaceRoot {
17588                    workspace_id: workspace_id.clone(),
17589                    path: root.to_owned(),
17590                    message: "path is not absolute".to_owned(),
17591                });
17592            }
17593            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17594                workspace_id: workspace_id.clone(),
17595                path: root.to_owned(),
17596                message: "path has no standalone directory name".to_owned(),
17597            })?;
17598            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17599                workspace_id: workspace_id.clone(),
17600                path: root.to_owned(),
17601                message: "path has no parent directory".to_owned(),
17602            })?;
17603            let canonical = std::fs::canonicalize(parent)
17604                .map(|parent| parent.join(name))
17605                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17606                    workspace_id: workspace_id.clone(),
17607                    path: root.to_owned(),
17608                    message: error.to_string(),
17609                })?;
17610            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17611                NodeServerError::InvalidWorkspaceRoot {
17612                    workspace_id: workspace_id.clone(),
17613                    path: path.to_string_lossy().into_owned(),
17614                    message: "canonical path is not valid Unicode".to_owned(),
17615                }
17616            })?;
17617            let canonical_root = platform::normalize_canonical_root(canonical_root);
17618            validate_workspace_root(workspace_id, &canonical_root)?;
17619            Ok(canonical_root)
17620        }
17621        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17622            workspace_id: workspace_id.clone(),
17623            path: root.to_owned(),
17624            message: error.to_string(),
17625        }),
17626    }
17627}
17628
17629fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17630    let Some(revision) = revision else {
17631        return Ok(());
17632    };
17633    if revision.is_empty()
17634        || revision.len() > MAX_REPOSITORY_PATH_BYTES
17635        || revision.chars().any(char::is_control)
17636    {
17637        return Err(failure(
17638            NodeFailureCode::InvalidRequest,
17639            &format!(
17640                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17641            ),
17642        ));
17643    }
17644    Ok(())
17645}
17646
17647fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17648    let code = match error.kind {
17649        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17650        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17651        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17652        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17653        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17654        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17655        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17656    };
17657    failure(code, &error.message)
17658}
17659
17660fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17661    let code = match error.kind {
17662        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17663        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17664        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17665            NodeFailureCode::BackendOperationFailed
17666        }
17667        StandaloneWorkspaceErrorKind::RecoveryRequired => {
17668            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17669        }
17670    };
17671    failure(code, error.message)
17672}
17673
17674fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17675    let (code, message) = match error.kind {
17676        GitWorktreeErrorKind::Invalid => (
17677            NodeFailureCode::InvalidRequest,
17678            "managed Git input is invalid",
17679        ),
17680        GitWorktreeErrorKind::NotRepository => (
17681            NodeFailureCode::NotGitRepository,
17682            "managed source is not a Git repository",
17683        ),
17684        GitWorktreeErrorKind::Conflict => (
17685            NodeFailureCode::WorktreeConflict,
17686            "managed Git identity conflicts with existing repository state",
17687        ),
17688        GitWorktreeErrorKind::Protected => (
17689            NodeFailureCode::ManagedWorktreeOwnershipConflict,
17690            "managed worktree ownership validation failed",
17691        ),
17692        GitWorktreeErrorKind::Dirty => (
17693            NodeFailureCode::WorktreeDirty,
17694            "managed worktree contains uncommitted changes",
17695        ),
17696        GitWorktreeErrorKind::Locked => (
17697            NodeFailureCode::WorktreeLocked,
17698            "managed worktree is locked",
17699        ),
17700        GitWorktreeErrorKind::Failed => (
17701            NodeFailureCode::BackendOperationFailed,
17702            "managed Git operation failed",
17703        ),
17704    };
17705    NodeFailure { code, message: message.to_owned() }
17706}
17707
17708fn managed_spawn_receipt(
17709    resolved: &ResolvedSpawnSpec,
17710    incarnation_id: NodeIncarnationId,
17711    session: SessionAddress,
17712    lease: ManagedWorktreeLeaseSnapshot,
17713    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17714    bundle: Option<ResolvedBundleReceipt>,
17715    context: Option<ResolvedContextPackReceipt>,
17716) -> ManagedWorktreeSpawnReceipt {
17717    let mut managed = resolved.clone();
17718    managed.target.worktree_id = Some(lease.workspace_id.clone());
17719    ManagedWorktreeSpawnReceipt {
17720        spawn: managed.receipt_with_materialization(
17721            incarnation_id,
17722            session,
17723            environment_profile,
17724            bundle,
17725            context,
17726        ),
17727        lease,
17728    }
17729}
17730
17731fn validate_selected_worktree(
17732    source_root: &str,
17733    selected_root: &str,
17734    worktrees: &[NativeGitWorktreeSnapshot],
17735) -> Result<(), NodeFailure> {
17736    if !worktrees
17737        .iter()
17738        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17739    {
17740        return Err(failure(
17741            NodeFailureCode::WorktreeProtected,
17742            "source workspace is not an authoritative non-bare Git worktree root",
17743        ));
17744    }
17745    if !worktrees.iter().any(|worktree| {
17746        worktree_paths_equal(&worktree.path, selected_root)
17747            && !worktree.is_main
17748            && !worktree.is_bare
17749    }) {
17750        return Err(failure(
17751            NodeFailureCode::WorktreeProtected,
17752            "selected workspace is not an eligible Git worktree in the source repository",
17753        ));
17754    }
17755    Ok(())
17756}
17757
17758fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17759    let code = match error.kind() {
17760        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17761        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17762            NodeFailureCode::RepositoryPathUnsafe
17763        }
17764        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17765        WorkspaceFileReadErrorKind::NotRegularFile => {
17766            NodeFailureCode::RepositoryFileNotRegular
17767        }
17768        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17769            NodeFailureCode::RepositoryFileReadFailed
17770        }
17771        WorkspaceFileReadErrorKind::RevisionConflict => {
17772            NodeFailureCode::RepositoryFileRevisionConflict
17773        }
17774        WorkspaceFileReadErrorKind::AlreadyExists
17775        | WorkspaceFileReadErrorKind::ParentNotFound
17776        | WorkspaceFileReadErrorKind::ParentNotDirectory
17777        | WorkspaceFileReadErrorKind::Canceled => {
17778            NodeFailureCode::RepositoryFileReadFailed
17779        }
17780    };
17781    failure(code, "repository file read failed")
17782}
17783
17784fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17785    let code = match error.kind() {
17786        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17787        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17788            NodeFailureCode::RepositoryPathUnsafe
17789        }
17790        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17791        WorkspaceFileReadErrorKind::NotRegularFile => {
17792            NodeFailureCode::RepositoryFileNotRegular
17793        }
17794        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17795            NodeFailureCode::RepositoryFileWriteFailed
17796        }
17797        WorkspaceFileReadErrorKind::RevisionConflict => {
17798            NodeFailureCode::RepositoryFileRevisionConflict
17799        }
17800        WorkspaceFileReadErrorKind::AlreadyExists
17801        | WorkspaceFileReadErrorKind::ParentNotFound
17802        | WorkspaceFileReadErrorKind::ParentNotDirectory
17803        | WorkspaceFileReadErrorKind::Canceled => {
17804            NodeFailureCode::RepositoryFileWriteFailed
17805        }
17806    };
17807    failure(code, "repository file write failed")
17808}
17809
17810async fn settle_workspace_entry_create<T>(
17811    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17812    commit_state: Arc<AtomicU8>,
17813    deadline: Duration,
17814    timeout_message: &'static str,
17815    task_failure_message: &'static str,
17816) -> Result<T, NodeFailure> {
17817    let joined = match timeout(deadline, &mut task).await {
17818        Ok(joined) => joined,
17819        Err(_) => {
17820            if commit_state
17821                .compare_exchange(
17822                    WORKSPACE_ENTRY_CREATE_PENDING,
17823                    WORKSPACE_ENTRY_CREATE_CANCELED,
17824                    Ordering::AcqRel,
17825                    Ordering::Acquire,
17826                )
17827                .is_ok()
17828            {
17829                return Err(failure(
17830                    NodeFailureCode::RepositoryEntryCreateTimedOut,
17831                    timeout_message,
17832                ));
17833            }
17834            debug_assert_eq!(
17835                commit_state.load(Ordering::Acquire),
17836                WORKSPACE_ENTRY_CREATE_COMMITTING,
17837            );
17838            task.await
17839        }
17840    };
17841    joined
17842        .map_err(|_| {
17843            failure(
17844                NodeFailureCode::RepositoryEntryCreateFailed,
17845                task_failure_message,
17846            )
17847        })?
17848        .map_err(workspace_entry_create_failure)
17849}
17850
17851fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17852    let code = match error.kind() {
17853        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17854        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17855            NodeFailureCode::RepositoryPathUnsafe
17856        }
17857        WorkspaceFileReadErrorKind::AlreadyExists => {
17858            NodeFailureCode::RepositoryEntryAlreadyExists
17859        }
17860        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17861            NodeFailureCode::RepositoryParentNotFound
17862        }
17863        WorkspaceFileReadErrorKind::ParentNotDirectory
17864        | WorkspaceFileReadErrorKind::NotRegularFile => {
17865            NodeFailureCode::RepositoryParentNotDirectory
17866        }
17867        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17868            NodeFailureCode::RepositoryEntryCreateFailed
17869        }
17870        WorkspaceFileReadErrorKind::RevisionConflict => {
17871            NodeFailureCode::RepositoryEntryCreateFailed
17872        }
17873        WorkspaceFileReadErrorKind::Canceled => {
17874            NodeFailureCode::RepositoryEntryCreateTimedOut
17875        }
17876    };
17877    failure(code, "repository entry create failed")
17878}
17879
17880fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17881    let value = digest(&SHA256, bytes)
17882        .as_ref()
17883        .iter()
17884        .map(|byte| format!("{byte:02x}"))
17885        .collect();
17886    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17887}
17888
17889fn managed_spawn_request_digest_v2(
17890    request: &ManagedWorktreeSpawnRequestV2,
17891) -> Result<String, NodeFailure> {
17892    let encoded = serde_json::to_vec(request).map_err(|_| {
17893        failure(
17894            NodeFailureCode::InvalidRequest,
17895            "managed worktree V2 request could not be canonicalized",
17896        )
17897    })?;
17898    let mut value = String::with_capacity(71);
17899    value.push_str("sha256:");
17900    for byte in digest(&SHA256, &encoded).as_ref() {
17901        use std::fmt::Write as _;
17902        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17903    }
17904    Ok(value)
17905}
17906
17907fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17908    let code = match error {
17909        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17910        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17911        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17912        NativeSessionPreviewError::UnsupportedProvider
17913        | NativeSessionPreviewError::PreviewUnavailable
17914        | NativeSessionPreviewError::SessionNotFound
17915        | NativeSessionPreviewError::AmbiguousSession => {
17916            NodeFailureCode::BackendOperationFailed
17917        }
17918    };
17919    failure(code, "native session preview failed")
17920}
17921
17922fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17923    let code = match error.kind() {
17924        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17925        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17926    };
17927    failure(code, "host directory browse failed")
17928}
17929
17930fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17931    let code = match error {
17932        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17933        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17934        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17935            NodeFailureCode::DeliveryStageConflict
17936        }
17937        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17938        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17939            NodeFailureCode::DeliveryChunkOutOfOrder
17940        }
17941        DeliveryStoreError::BlobDigestMismatch => {
17942            NodeFailureCode::DeliveryBlobDigestMismatch
17943        }
17944        DeliveryStoreError::BundleDigestMismatch => {
17945            NodeFailureCode::DeliveryBundleDigestMismatch
17946        }
17947        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17948        DeliveryStoreError::Unavailable
17949        | DeliveryStoreError::Corrupt
17950        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17951    };
17952    failure(code, "delivery operation failed")
17953}
17954
17955fn validate_workspace_root(
17956    workspace_id: &WorkspaceId,
17957    canonical_root: &str,
17958) -> Result<(), NodeServerError> {
17959    #[cfg(windows)]
17960    if canonical_root.starts_with(r"\\") {
17961        return Err(NodeServerError::InvalidWorkspaceRoot {
17962            workspace_id: workspace_id.clone(),
17963            path: canonical_root.to_owned(),
17964            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17965        });
17966    }
17967    if !platform::workspace_root_supported(canonical_root)
17968        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17969    {
17970        return Err(NodeServerError::InvalidWorkspaceRoot {
17971            workspace_id: workspace_id.clone(),
17972            path: canonical_root.to_owned(),
17973            message: "canonical path must be absolute, bounded, and free of control characters"
17974                .to_owned(),
17975        });
17976    }
17977    Ok(())
17978}
17979
17980fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17981    NodeFailure {
17982        code,
17983        message: node_failure_category(code).to_owned(),
17984    }
17985}
17986
17987fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17988    let code = match error {
17989        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17990        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17991        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17992        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17993        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17994        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17995        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17996        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17997        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17998    };
17999    failure(code, "harness MCP proxy operation failed")
18000}
18001
18002/// Numbers each harness-MCP overlay this process constructs, in
18003/// construction order, so two overlays built back to back in one node process
18004/// still get distinct trace files.
18005static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18006
18007/// Builds the optional trace environment entry of one harness-MCP overlay.
18008/// The caller's launch description names both the entry and the node-process
18009/// variable that opts in: when that variable holds a non-empty directory, the
18010/// entry names a trace file under it. Returns `None` when the launch has no
18011/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
18012/// the directory; that is the operator's job.
18013fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18014    let trace = launch.trace.as_ref()?;
18015    let trace_dir = std::env::var(&trace.dir_env).ok()?;
18016    if trace_dir.is_empty() {
18017        return None;
18018    }
18019    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18020    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18021    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18022}
18023
18024fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18025    let remaining = deadline.saturating_duration_since(Instant::now());
18026    if remaining.is_zero() {
18027        Err(failure(
18028            NodeFailureCode::SpawnDeadlineExceeded,
18029            "spawn deadline elapsed",
18030        ))
18031    } else {
18032        Ok(remaining)
18033    }
18034}
18035
18036fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18037    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18038    match deadline {
18039        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18040        None => Ok(bounded),
18041    }
18042}
18043
18044fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18045    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18046        failure(
18047            NodeFailureCode::SpawnDeadlineExceeded,
18048            "spawn dispatch exceeded the spawn deadline",
18049        )
18050    } else {
18051        error
18052    }
18053}
18054
18055fn spawn_runtime_capabilities(
18056    capabilities: &SpawnRequiredCapabilities,
18057) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18058    capabilities
18059        .iter()
18060        .map(|capability| match capability.as_str() {
18061            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18062            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18063            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18064            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18065                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18066            }
18067            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18068            _ => Err(failure(
18069                NodeFailureCode::UnsupportedSpawnCapability,
18070                "spawn required capability is unknown",
18071            )),
18072        })
18073        .collect()
18074}
18075
18076fn persistence_failure(_error: io::Error) -> NodeFailure {
18077    NodeFailure {
18078        code: NodeFailureCode::BackendOperationFailed,
18079        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18080    }
18081}
18082
18083fn node_failure_category(code: NodeFailureCode) -> &'static str {
18084    match code {
18085        NodeFailureCode::InvalidRequest => "invalid-request",
18086        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18087        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18088        NodeFailureCode::ReservationNotFound => "reservation-not-found",
18089        NodeFailureCode::ReservationConflict => "reservation-conflict",
18090        NodeFailureCode::ReservationExpired => "reservation-expired",
18091        NodeFailureCode::BindingMismatch => "binding-mismatch",
18092        NodeFailureCode::NotActivated => "not-activated",
18093        NodeFailureCode::CallNotFound => "call-not-found",
18094        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18095        NodeFailureCode::ResponseTooLarge => "response-too-large",
18096        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18097        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18098        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18099        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18100        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18101        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18102        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18103        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18104        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18105        NodeFailureCode::Unauthorized => "unauthorized",
18106        NodeFailureCode::ObserverReadOnly => "observer-read-only",
18107        NodeFailureCode::ControllerBusy => "controller-busy",
18108        NodeFailureCode::ControllerRequired => "controller-required",
18109        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18110        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18111        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18112        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18113        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18114        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18115        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18116        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18117        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18118        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18119        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18120        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18121        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18122        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18123        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18124        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18125        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18126        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18127        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18128        NodeFailureCode::GitReadFailed => "git-read-failed",
18129        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18130        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18131        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18132        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18133        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18134        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18135        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18136        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18137        NodeFailureCode::TurnInFlight => "turn-in-flight",
18138        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18139        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18140        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18141            "unsupported-network-allowlist-mapping"
18142        }
18143        NodeFailureCode::BrowserStationProbeUnavailable => {
18144            "browser-station-probe-unavailable"
18145        }
18146        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18147        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18148        NodeFailureCode::EnvironmentProfileBindingMismatch => {
18149            "environment-profile-binding-mismatch"
18150        }
18151        NodeFailureCode::UnknownBundle => "unknown-bundle",
18152        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18153        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18154        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18155        NodeFailureCode::ContextPackBusy => "context-pack-busy",
18156        NodeFailureCode::ContextPackMaterializationFailed => {
18157            "context-pack-materialization-failed"
18158        }
18159        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18160        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18161        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18162        NodeFailureCode::WorkspaceBusy => "workspace-busy",
18163        NodeFailureCode::LastWorkspace => "last-workspace",
18164        NodeFailureCode::NotGitRepository => "not-git-repository",
18165        NodeFailureCode::WorktreeConflict => "worktree-conflict",
18166        NodeFailureCode::WorktreeProtected => "worktree-protected",
18167        NodeFailureCode::WorktreeDirty => "worktree-dirty",
18168        NodeFailureCode::WorktreeLocked => "worktree-locked",
18169        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18170        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18171        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18172        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18173            "managed-worktree-profile-revision-mismatch"
18174        }
18175        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18176        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18177            "standalone-workspace-recovery-required"
18178        }
18179        NodeFailureCode::UnknownSession => "unknown-session",
18180        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18181        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18182        NodeFailureCode::SessionRecordBusy => "session-record-busy",
18183        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18184        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18185        NodeFailureCode::WorkspaceRegistrationRequired => {
18186            "workspace-registration-required"
18187        }
18188        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18189        NodeFailureCode::StaleGeneration => "stale-generation",
18190        NodeFailureCode::BackendBusy => "backend-busy",
18191        NodeFailureCode::BackendDisconnected => "backend-disconnected",
18192        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18193        NodeFailureCode::ShuttingDown => "shutting-down",
18194    }
18195}
18196
18197fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18198    NodeServerError::DurableState(io::Error::new(error.kind(), category))
18199}
18200
18201fn durable_state_load_error(error: io::Error) -> NodeServerError {
18202    let category = match session_registry::state_load_refusal(&error) {
18203        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18204            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18205        }
18206        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18207            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18208        }
18209        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18210            DURABLE_STATE_CONFLICT_ERROR
18211        }
18212        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18213    };
18214    durable_state_server_error(error, category)
18215}
18216
18217fn unix_time_ms() -> u64 {
18218    std::time::SystemTime::now()
18219        .duration_since(std::time::UNIX_EPOCH)
18220        .unwrap_or_default()
18221        .as_millis()
18222        .min(u64::MAX as u128) as u64
18223}
18224
18225fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18226    if record.provider_session.is_some() {
18227        ManagedSessionState::Dormant
18228    } else {
18229        ManagedSessionState::Unavailable
18230    }
18231}
18232
18233fn active_record_state(
18234    identity_admitted: bool,
18235    provider_session_is_known: bool,
18236) -> ManagedSessionState {
18237    if identity_admitted && !provider_session_is_known {
18238        ManagedSessionState::IdentityPending
18239    } else {
18240        ManagedSessionState::Live
18241    }
18242}
18243
18244fn managed_worktree_record_holders(
18245    records: &[ManagedSessionRecord],
18246) -> Vec<(SessionRecordId, WorkspaceId)> {
18247    records
18248        .iter()
18249        .filter(|record| record.provider_session.is_some())
18250        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18251        .collect()
18252}
18253
18254fn managed_spawn_record_ownership(
18255    binding: &SessionBinding,
18256    effective_runtime_policy: ProviderRuntimePolicy,
18257) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18258    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18259    if effective_runtime_policy.provider_session_identity {
18260        (binding.record_id.clone(), None)
18261    } else {
18262        (None, binding.record_id.clone())
18263    }
18264}
18265
18266#[derive(Debug, Error)]
18267pub enum NodeServerError {
18268    #[error("node endpoint must be a bounded absolute local transport path")]
18269    InvalidEndpoint,
18270    #[error("node access token must contain 1..=4096 bytes")]
18271    InvalidAccessToken,
18272    #[error("node HTTP observer must listen on a loopback address: {0}")]
18273    InvalidApiListen(std::net::SocketAddr),
18274    #[error("node browser bridge must listen on a loopback address: {0}")]
18275    InvalidBridgeListen(std::net::SocketAddr),
18276    #[error("node browser bridge token must contain 1..=4096 bytes")]
18277    InvalidBridgeToken,
18278    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18279    BridgeUnderlayRequiresBridgeListen,
18280    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18281    BridgeUnderlayRequiresBridgeToken,
18282    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18283    InvalidBridgeUnderlayToken,
18284    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18285    InvalidBridgeUnderlayKey,
18286    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18287    InvalidCallHome(std::net::SocketAddr),
18288    #[error("node could not reach its call-home relay: {0}")]
18289    CallHomeTransport(#[source] std::io::Error),
18290    #[error("node durable state path must be an absolute file path: {0}")]
18291    InvalidStatePath(String),
18292    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18293    InvalidHarnessMcpHelper,
18294    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18295    InvalidNetworkAllowlistCatalogPath(String),
18296    #[error("network allowlist catalog could not be read: {0}")]
18297    NetworkAllowlistCatalogIo(#[source] std::io::Error),
18298    #[error("network allowlist catalog line {line}: {message}")]
18299    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18300    #[error("network allowlist catalog: {0}")]
18301    InvalidNetworkAllowlistCatalog(String),
18302    #[error("network allowlist catalog exceeds the {max}-entry limit")]
18303    NetworkAllowlistCatalogCapacity { max: usize },
18304    #[error("the local state directory is unavailable; supply an explicit state path")]
18305    LocalStateDirectoryUnavailable,
18306    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18307    LocalRuntimeDirectoryUnavailable,
18308    #[error("node durable state failed: {0}")]
18309    DurableState(io::Error),
18310    #[error("node requires at least one configured workspace")]
18311    NoWorkspaces,
18312    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18313    InvalidWorkspaceRoot {
18314        workspace_id: WorkspaceId,
18315        path: String,
18316        message: String,
18317    },
18318    #[error("duplicate workspace ID: {0}")]
18319    DuplicateWorkspaceId(WorkspaceId),
18320    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18321    DuplicateWorkspaceRoot {
18322        first: WorkspaceId,
18323        second: WorkspaceId,
18324        root: String,
18325    },
18326    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18327    ManagedWorktreeProfileRequired(WorkspaceId),
18328    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18329    ManagedWorktreeProfileModeMismatch(WorkspaceId),
18330    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18331    DuplicateManagedWorktreeProfile {
18332        workspace_id: WorkspaceId,
18333        profile_id: WorktreeProfileId,
18334    },
18335    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18336    ManagedWorktreeProfileCapacity {
18337        workspace_id: WorkspaceId,
18338        max: usize,
18339    },
18340    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18341    InvalidManagedWorktreeProfile {
18342        workspace_id: WorkspaceId,
18343        profile_id: WorktreeProfileId,
18344        message: String,
18345    },
18346    #[error("duplicate node environment profile: {0}")]
18347    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18348    #[error("node environment profile provider is unavailable: {0}")]
18349    EnvironmentProfileProviderUnavailable(AgentId),
18350    #[error("node environment profile capacity is {max}")]
18351    EnvironmentProfileCapacity { max: usize },
18352    #[error("node environment profiles reuse native profile ID '{0}'")]
18353    DuplicateNativeEnvironmentProfile(String),
18354    #[error("node native environment profile is invalid: {0}")]
18355    NativeEnvironmentProfile(String),
18356    #[error("node session-environment materialization root must be absolute")]
18357    InvalidSessionEnvironmentRoot,
18358    #[error("node session-environment materializer is unavailable")]
18359    SessionEnvironmentMaterializerRequired,
18360    #[error("node session-environment materializer failed to initialize")]
18361    SessionEnvironmentMaterializer,
18362    #[error("node bundle catalog is invalid: {0}")]
18363    BundleCatalog(String),
18364    #[error("node delivery store failed to initialize: {0}")]
18365    DeliveryStore(String),
18366    #[error("node context pack store failed to initialize")]
18367    ContextPackStore,
18368    #[error("active agent registry failed: {0}")]
18369    Registry(String),
18370    #[error("node provider contract manifest is invalid: {0}")]
18371    ProviderContractManifest(String),
18372    #[error("named pipe I/O failed: {0}")]
18373    Io(#[from] io::Error),
18374    #[error("node HTTP observer failed: {0}")]
18375    HttpApi(io::Error),
18376    #[error("node browser bridge failed: {0}")]
18377    BridgeApi(io::Error),
18378    #[error(transparent)]
18379    Frame(#[from] FrameError),
18380    #[error("node handshake failed: {0}")]
18381    Handshake(String),
18382    #[error("node authentication frame was not received before the bounded deadline")]
18383    AuthenticationTimedOut,
18384    #[error("node authentication primitive failed: {0}")]
18385    Authentication(String),
18386    #[error("node incarnation identity generation failed: {0}")]
18387    IncarnationIdentity(String),
18388    #[error("node pre-authentication limiter is closed")]
18389    PreauthClosed,
18390    #[error("node authenticated connection limit was reached")]
18391    AuthenticatedConnectionLimit,
18392    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18393    SignalStreamClosed,
18394    #[error("node stop-all dispatch failed: {0:?}")]
18395    ShutdownDispatch(NodeFailure),
18396    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18397    ShutdownTimedOut { active_native_sessions: usize },
18398}
18399
18400impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18401    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18402        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18403        match error {
18404            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18405            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18406            E::InvalidEntry { line, message } => {
18407                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18408            }
18409            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18410            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18411        }
18412    }
18413}
18414
18415#[cfg(test)]
18416mod agent_stream_blocked_tests {
18417    use super::*;
18418    use gate4agent_types::{
18419        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18420        ProviderSource,
18421    };
18422
18423    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18424        ProviderSource {
18425            family,
18426            binding: AdapterBinding::new(
18427                AdapterId::new(adapter).unwrap(),
18428                "fixture/v1",
18429                AdapterVerification::SyntheticFixture,
18430            )
18431            .unwrap(),
18432        }
18433    }
18434
18435    fn provider_control_event_at(
18436        family: AdapterFamily,
18437        adapter: &str,
18438        source_sequence: u64,
18439        provider_event: ProviderEvent,
18440    ) -> ControlEvent {
18441        ControlEvent {
18442            sequence: 41,
18443            command_id: None,
18444            instance_id: AgentInstanceId(7),
18445            generation: SessionGeneration(3),
18446            event: ControlEventKind::ProviderEvent {
18447                sequence: 8,
18448                source: provider_source(family, adapter),
18449                source_sequence,
18450                event: provider_event,
18451            },
18452        }
18453    }
18454
18455    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18456        provider_control_event_at(family, "fixture", 9, provider_event)
18457    }
18458
18459    fn denied_host_request(
18460        by: ProviderHostDecisionAuthority,
18461        reason: Option<String>,
18462    ) -> ControlEvent {
18463        provider_control_event_at(
18464            AdapterFamily::Acp,
18465            "claude-code",
18466            2,
18467            ProviderEvent::HostRequestObserved {
18468                method: "terminal/create".to_owned(),
18469                params_json: String::new(),
18470                decision: ProviderHostRequestDecision::Denied { by },
18471                outcome: ProviderHostRequestOutcome::Executed,
18472                reason,
18473            },
18474        )
18475    }
18476
18477    fn blocked(
18478        event: &ControlEvent,
18479    ) -> (
18480        Option<String>,
18481        String,
18482        BlockAuthorityV1,
18483        Option<String>,
18484        String,
18485        Option<String>,
18486    ) {
18487        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18488        chunk.validate().expect("minted chunk must validate");
18489        let AgentStreamChunkKindV1::Blocked {
18490            correlation_id,
18491            tool_class,
18492            authority,
18493            reason_kind,
18494            reason,
18495            help,
18496        } = chunk.kind
18497        else {
18498            panic!("expected AgentStreamChunkKindV1::Blocked");
18499        };
18500        (correlation_id, tool_class, authority, reason_kind, reason, help)
18501    }
18502
18503    /// Pins the fleet and every adapter each member declares: the fleet is
18504    /// exactly what `active_registry` says, and no member declares a hook
18505    /// adapter (lifecycle hooks are retired).
18506    #[test]
18507    fn the_enabled_provider_matrix_is_exact() {
18508        let registry = active_registry().unwrap();
18509        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
18510        assert_eq!(
18511            providers
18512                .iter()
18513                .map(|contract| contract.provider.as_str())
18514                .collect::<Vec<_>>(),
18515            // `shell` is the plain PTY provider (operator terminals and smoke tests).
18516            vec!["shell", "claude", "codex", "grok", "kimi"],
18517        );
18518        assert!(adapters.iter().all(|contract| {
18519            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18520        }));
18521    }
18522
18523    /// A gate-denied host request, WITH its reason text carried all the way
18524    /// from the ACP host, mints a `Blocked` chunk naming the gate.
18525    #[test]
18526    fn gate_denied_host_request_mints_a_blocked_chunk() {
18527        let gate_text =
18528            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18529        let event = denied_host_request(
18530            ProviderHostDecisionAuthority::Gate,
18531            Some(gate_text.to_owned()),
18532        );
18533        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18534        assert_eq!(chunk.source_sequence, 2);
18535        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18536        assert_eq!(authority, BlockAuthorityV1::HostGate);
18537        assert_eq!(reason, gate_text);
18538        assert_eq!(correlation_id, None);
18539        assert_eq!(reason_kind, None);
18540        assert_eq!(help, None);
18541        assert_eq!(tool_class, "Write");
18542    }
18543
18544    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.";
18545
18546    /// The owner's classifier sample, verbatim, arriving as a
18547    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
18548    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
18549    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
18550    /// and the guidance tail as `help`.
18551    #[test]
18552    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18553        let event = provider_control_event(
18554            AdapterFamily::Acp,
18555            ProviderEvent::ToolCompleted {
18556                id: "toolu_9".to_owned(),
18557                output: CLASSIFIER_SAMPLE.to_owned(),
18558                is_error: true,
18559                duration_ms: None,
18560                agent_id: None,
18561                non_execution_kind: Some("permission-rule".to_owned()),
18562            },
18563        );
18564        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18565        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18566        assert_eq!(reason, "Blocked by classifier");
18567        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18568        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18569        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18570    }
18571
18572    /// A `session/prompt` RPC error with `data.codexErrorInfo:
18573    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
18574    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
18575    /// vendor code as `reason_kind`.
18576    #[test]
18577    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18578        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.";
18579        let event = provider_control_event(
18580            AdapterFamily::Acp,
18581            ProviderEvent::SessionEnded {
18582                result: "JSON-RPC error -32603: Internal error".to_owned(),
18583                cost_usd: None,
18584                is_error: true,
18585                stop_reason: Some(ProviderStopReason::ProviderError {
18586                    code: -32603,
18587                    message: message.to_owned(),
18588                    vendor_code: Some("usageLimitExceeded".to_owned()),
18589                }),
18590            },
18591        );
18592        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18593        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18594        assert_eq!(reason, message);
18595        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18596        assert_eq!(tool_class, "Session");
18597    }
18598
18599    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
18600    /// `authority: ProviderRefusal`.
18601    #[test]
18602    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18603        let event = provider_control_event(
18604            AdapterFamily::Acp,
18605            ProviderEvent::SessionEnded {
18606                result: "refusal".to_owned(),
18607                cost_usd: None,
18608                is_error: true,
18609                stop_reason: Some(ProviderStopReason::Refusal),
18610            },
18611        );
18612        let (_, _, authority, _, _, _) = blocked(&event);
18613        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18614    }
18615
18616    /// A failed tool completion with no typed `non_execution_kind` -- or one
18617    /// that only says the turn stopped -- is a plain execution failure, never a
18618    /// block; nor is an agent's own narrative claiming one.
18619    #[test]
18620    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18621        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18622            let event = provider_control_event(
18623                AdapterFamily::Acp,
18624                ProviderEvent::ToolCompleted {
18625                    id: "toolu_1".to_owned(),
18626                    output: "command exited with status 1".to_owned(),
18627                    is_error: true,
18628                    duration_ms: Some(12),
18629                    agent_id: None,
18630                    non_execution_kind,
18631                },
18632            );
18633            assert!(agent_stream_chunk(&event).is_none());
18634        }
18635        let narrative = provider_control_event(
18636            AdapterFamily::Acp,
18637            ProviderEvent::ToolCompleted {
18638                id: "toolu_2".to_owned(),
18639                output: "the harness blocked the write, approval needed".to_owned(),
18640                is_error: false,
18641                duration_ms: Some(3),
18642                agent_id: None,
18643                non_execution_kind: None,
18644            },
18645        );
18646        assert!(agent_stream_chunk(&narrative).is_none());
18647    }
18648
18649    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
18650    /// execution outcome: a granted request that failed while running was
18651    /// still authorized.
18652    #[test]
18653    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18654        for (decision, outcome) in [
18655            (
18656                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18657                ProviderHostRequestOutcome::Executed,
18658            ),
18659            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18660            (
18661                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18662                ProviderHostRequestOutcome::Failed {
18663                    error: "terminal/create spawn failed: os error 3".to_owned(),
18664                },
18665            ),
18666        ] {
18667            let event = provider_control_event_at(
18668                AdapterFamily::Acp,
18669                "claude-code",
18670                3,
18671                ProviderEvent::HostRequestObserved {
18672                    method: "fs/read_text_file".to_owned(),
18673                    params_json: String::new(),
18674                    decision: decision.clone(),
18675                    outcome,
18676                    reason: None,
18677                },
18678            );
18679            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18680        }
18681    }
18682
18683    /// A deferred `session/request_permission` resolved by an operator or an
18684    /// expired deadline carries no reason text -- the chunk falls back to a
18685    /// categorical sentence naming the authority rather than inventing detail.
18686    #[test]
18687    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18688        for (by, expected_authority, expected_reason) in [
18689            (
18690                ProviderHostDecisionAuthority::Operator,
18691                BlockAuthorityV1::Operator,
18692                "declined by the operator",
18693            ),
18694            (
18695                ProviderHostDecisionAuthority::DeadlinePolicy,
18696                BlockAuthorityV1::HostDeadline,
18697                "denied by host policy after the operator deadline expired",
18698            ),
18699        ] {
18700            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18701            assert_eq!(authority, expected_authority);
18702            assert_eq!(reason, expected_reason);
18703        }
18704    }
18705
18706    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
18707    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
18708    /// outright rather than truncating.
18709    #[test]
18710    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18711        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18712        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18713            ProviderHostDecisionAuthority::Policy,
18714            Some(oversized),
18715        ));
18716        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18717    }
18718}
18719
18720#[cfg(test)]
18721mod standalone_capability_platform_tests {
18722    use super::*;
18723
18724    #[test]
18725    fn standalone_workspace_capability_requires_windows_identity_guards() {
18726        let capabilities = baseline_capabilities().unwrap();
18727        let advertised = capabilities.iter().any(|capability| {
18728            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18729        });
18730        assert_eq!(advertised, cfg!(windows));
18731
18732        let request = NodeRequest::CreateStandaloneWorkspace {
18733            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18734            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18735            initial_branch: None,
18736        };
18737        assert_eq!(
18738            request_uses_unnegotiated_capability(&request, &capabilities),
18739            !cfg!(windows),
18740        );
18741    }
18742}
18743
18744
18745#[cfg(test)]
18746mod station_knobs_resolve_tests {
18747    use super::*;
18748    use gate4agent_catalog::EnvMutation;
18749    use gate4agent_runtime_native::{
18750        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18751        NativeLaunchProfileId,
18752    };
18753    use std::ffi::OsString;
18754    use std::sync::Arc;
18755
18756    struct EmptyEnvironmentResolver;
18757
18758    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18759        fn resolve_child_environment(
18760            &self,
18761        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18762            Ok(vec![EnvMutation {
18763                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18764                value: None,
18765            }])
18766        }
18767    }
18768
18769    fn agent(value: &str) -> AgentId {
18770        AgentId::new(value).unwrap()
18771    }
18772
18773    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18774        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18775        let profile_revision =
18776            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18777        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18778            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18779            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18780            provider: agent("claude"),
18781            mode: SessionMode::Pty,
18782            terminal_size: gate4agent_types::TerminalSize {
18783                rows: 24,
18784                columns: 80,
18785            },
18786            prompt: None,
18787            bundle_id: None,
18788            context_id: None,
18789            environment_profile_id: Some(profile_id.clone()),
18790        };
18791        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18792        let catalog = active_registry().unwrap();
18793        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18794        let control = runtime.native_launch_profile_control();
18795        let node_profile = NodeEnvironmentProfile::new(
18796            profile_id.clone(),
18797            profile_revision,
18798            agent("claude"),
18799            [NativeLaunchProfile::new(
18800                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18801                agent("claude"),
18802                TransportKind::Pty,
18803                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18804                Arc::new(EmptyEnvironmentResolver),
18805            )
18806            .unwrap()],
18807        )
18808        .unwrap();
18809        let (binding, native_profiles, _materialization) = node_profile.into_parts();
18810        for native_profile in native_profiles {
18811            runtime.upsert_native_launch_profile(native_profile).unwrap();
18812        }
18813        let workspace = WorkspaceConfig::new(
18814            WorkspaceId::new("primary").unwrap(),
18815            std::env::current_dir().unwrap(),
18816        )
18817        .unwrap();
18818        let shared = NodeShared::new_with_incarnation(
18819            handle,
18820            "fixture-token".to_owned(),
18821            NodeId::new("node-station-knobs-test").unwrap(),
18822            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18823            vec![workspace],
18824            vec![agent("claude")],
18825            ProviderRuntimeStatuses::default(),
18826            None,
18827            Vec::new(),
18828            Vec::new(),
18829            spawn_profiles,
18830            Some(control),
18831            None,
18832            None,
18833            None,
18834            Vec::new(),
18835            Vec::new(),
18836            Vec::new(),
18837            Vec::new(),
18838            None,
18839            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18840        );
18841        shared
18842            .environment_profiles
18843            .write()
18844            .unwrap_or_else(|poisoned| poisoned.into_inner())
18845            .insert(binding.id.clone(), binding);
18846        (shared, spawn_defaults)
18847    }
18848
18849    /// Empty-default catalog refuses unknown network_allowlist ids.
18850    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
18851    #[test]
18852    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18853        let (shared, defaults) = station_knobs_fixture();
18854        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18855        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18856        let spec = crate::protocol::SpawnSpec {
18857            target: crate::protocol::SpawnTarget {
18858                node_id: shared.node_id.clone(),
18859                workspace_id: WorkspaceId::new("primary").unwrap(),
18860                worktree_id: None,
18861            },
18862            profile_id: defaults.profile_id.clone(),
18863            expected_profile_revision: defaults.revision.clone(),
18864            overrides: crate::protocol::SpawnOverrides {
18865                network_allowlist: Some(unknown),
18866                browser_profile_id: Some(browser),
18867                ..crate::protocol::SpawnOverrides::default()
18868            },
18869            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18870            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18871                "station-knobs-unknown-allowlist",
18872            )
18873            .unwrap(),
18874            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18875        };
18876        let resolved = spec.resolve(&defaults).unwrap();
18877        let failure = shared
18878            .resolve_environment_profile(&resolved)
18879            .expect_err("unknown allowlist must refuse");
18880        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18881    }
18882
18883    /// Registered network_allowlist (+ browser_profile_id when feature off)
18884    /// echo onto the env-profile receipt. Empty catalog still allows omit
18885    /// (None). Empty/whitespace refuse is type-level. Feature-off
18886    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
18887    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
18888    #[test]
18889    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18890        let (shared, defaults) = station_knobs_fixture();
18891        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18892        #[cfg(not(feature = "dig2-station-probe"))]
18893        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18894        shared
18895            .network_allowlist_catalog
18896            .write()
18897            .unwrap_or_else(|poisoned| poisoned.into_inner())
18898            .insert_id(allowlist.clone())
18899            .unwrap();
18900        let spec = crate::protocol::SpawnSpec {
18901            target: crate::protocol::SpawnTarget {
18902                node_id: shared.node_id.clone(),
18903                workspace_id: WorkspaceId::new("primary").unwrap(),
18904                worktree_id: None,
18905            },
18906            profile_id: defaults.profile_id.clone(),
18907            expected_profile_revision: defaults.revision.clone(),
18908            overrides: crate::protocol::SpawnOverrides {
18909                network_allowlist: Some(allowlist.clone()),
18910                #[cfg(not(feature = "dig2-station-probe"))]
18911                browser_profile_id: Some(browser.clone()),
18912                ..crate::protocol::SpawnOverrides::default()
18913            },
18914            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18915            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18916                .unwrap(),
18917            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18918        };
18919        let resolved = spec.resolve(&defaults).unwrap();
18920        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18921        #[cfg(not(feature = "dig2-station-probe"))]
18922        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18923        let receipt = shared
18924            .resolve_environment_profile(&resolved)
18925            .expect("resolve ok")
18926            .expect("env profile present");
18927        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18928        #[cfg(not(feature = "dig2-station-probe"))]
18929        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18930        #[cfg(feature = "dig2-station-probe")]
18931        assert_eq!(receipt.browser_profile_id, None);
18932        assert_eq!(receipt.profile_id.as_str(), "local-claude");
18933
18934        let omitted = crate::protocol::SpawnSpec {
18935            overrides: crate::protocol::SpawnOverrides::default(),
18936            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18937                .unwrap(),
18938            ..spec
18939        };
18940        let resolved_omit = omitted.resolve(&defaults).unwrap();
18941        let bare = shared
18942            .resolve_environment_profile(&resolved_omit)
18943            .expect("resolve ok")
18944            .expect("env profile present");
18945        assert_eq!(bare.network_allowlist, None);
18946        assert_eq!(bare.browser_profile_id, None);
18947    }
18948
18949    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
18950    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
18951    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
18952    /// unit-tested separately (probe refuses before lease on Linux).
18953    #[cfg(feature = "dig2-station-probe")]
18954    #[test]
18955    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18956        let (shared, defaults) = station_knobs_fixture();
18957        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18958        let spec = crate::protocol::SpawnSpec {
18959            target: crate::protocol::SpawnTarget {
18960                node_id: shared.node_id.clone(),
18961                workspace_id: WorkspaceId::new("primary").unwrap(),
18962                worktree_id: None,
18963            },
18964            profile_id: defaults.profile_id.clone(),
18965            expected_profile_revision: defaults.revision.clone(),
18966            overrides: crate::protocol::SpawnOverrides {
18967                browser_profile_id: Some(browser),
18968                ..crate::protocol::SpawnOverrides::default()
18969            },
18970            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18971            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18972                "station-knobs-probe-refuse",
18973            )
18974            .unwrap(),
18975            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18976        };
18977        let resolved = spec.resolve(&defaults).unwrap();
18978        let failure = shared
18979            .resolve_environment_profile(&resolved)
18980            .expect_err("browser_profile with failing probe must refuse");
18981        #[cfg(windows)]
18982        {
18983            assert!(
18984                matches!(
18985                    failure.code,
18986                    NodeFailureCode::BrowserStationUnreachable
18987                        | NodeFailureCode::BrowserStationProbeUnavailable
18988                ),
18989                "unexpected code {:?}",
18990                failure.code
18991            );
18992        }
18993        #[cfg(not(windows))]
18994        {
18995            assert_eq!(
18996                failure.code,
18997                NodeFailureCode::BrowserStationProbeUnavailable
18998            );
18999        }
19000    }
19001
19002    /// Feature on: omitting browser_profile_id still resolves (no probe).
19003    #[cfg(feature = "dig2-station-probe")]
19004    #[test]
19005    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19006        let (shared, defaults) = station_knobs_fixture();
19007        let spec = crate::protocol::SpawnSpec {
19008            target: crate::protocol::SpawnTarget {
19009                node_id: shared.node_id.clone(),
19010                workspace_id: WorkspaceId::new("primary").unwrap(),
19011                worktree_id: None,
19012            },
19013            profile_id: defaults.profile_id.clone(),
19014            expected_profile_revision: defaults.revision.clone(),
19015            overrides: crate::protocol::SpawnOverrides::default(),
19016            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19017            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19018                "station-knobs-probe-omit",
19019            )
19020            .unwrap(),
19021            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19022        };
19023        let resolved = spec.resolve(&defaults).unwrap();
19024        let receipt = shared
19025            .resolve_environment_profile(&resolved)
19026            .expect("omit browser_profile must not probe")
19027            .expect("env profile present");
19028        assert_eq!(receipt.browser_profile_id, None);
19029    }
19030
19031    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
19032    /// refuses a second holder for the same opaque profile id; release on
19033    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
19034    #[cfg(feature = "dig2-station-probe")]
19035    #[test]
19036    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19037        let (shared, _defaults) = station_knobs_fixture();
19038        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19039        let workspace_id = WorkspaceId::new("primary").unwrap();
19040        let address_a = SessionAddress {
19041            workspace_id: workspace_id.clone(),
19042            session: SessionKey {
19043                instance_id: AgentInstanceId(101),
19044                generation: SessionGeneration(1),
19045            },
19046        };
19047        let address_b = SessionAddress {
19048            workspace_id: workspace_id.clone(),
19049            session: SessionKey {
19050                instance_id: AgentInstanceId(102),
19051                generation: SessionGeneration(1),
19052            },
19053        };
19054        let guard_a = crate::dig2_station_lease::acquire_lease(
19055            &shared.browser_station_leases,
19056            profile.clone(),
19057            address_a.session.instance_id,
19058            1,
19059        )
19060        .expect("first lease");
19061        let busy = crate::dig2_station_lease::acquire_lease(
19062            &shared.browser_station_leases,
19063            profile.clone(),
19064            address_b.session.instance_id,
19065            2,
19066        )
19067        .expect_err("second holder must refuse");
19068        assert!(matches!(
19069            busy,
19070            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19071        ));
19072        // Simulate a bound session so remove_binding clears the lease.
19073        shared.bind_session_with_policy(
19074            &address_a,
19075            ProviderRuntimePolicy::raw_pty(),
19076            None,
19077        );
19078        guard_a.retain();
19079        assert!(shared.remove_binding(&address_a).is_some());
19080        crate::dig2_station_lease::acquire_lease(
19081            &shared.browser_station_leases,
19082            profile,
19083            address_b.session.instance_id,
19084            3,
19085        )
19086        .expect("reacquire after clear")
19087        .retain();
19088        assert!(
19089            crate::dig2_station_lease::release_lease_for_holder(
19090                &shared.browser_station_leases,
19091                address_b.session.instance_id,
19092            )
19093        );
19094    }
19095
19096    /// Feature-off: no browser_station_leases field; stub echo documented in
19097    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
19098    #[cfg(not(feature = "dig2-station-probe"))]
19099    #[test]
19100    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19101        let (shared, defaults) = station_knobs_fixture();
19102        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19103        let spec = crate::protocol::SpawnSpec {
19104            target: crate::protocol::SpawnTarget {
19105                node_id: shared.node_id.clone(),
19106                workspace_id: WorkspaceId::new("primary").unwrap(),
19107                worktree_id: None,
19108            },
19109            profile_id: defaults.profile_id.clone(),
19110            expected_profile_revision: defaults.revision.clone(),
19111            overrides: crate::protocol::SpawnOverrides {
19112                browser_profile_id: Some(browser.clone()),
19113                ..crate::protocol::SpawnOverrides::default()
19114            },
19115            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19116            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19117                .unwrap(),
19118            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19119        };
19120        let resolved = spec.resolve(&defaults).unwrap();
19121        let receipt = shared
19122            .resolve_environment_profile(&resolved)
19123            .expect("stub resolve")
19124            .expect("env profile");
19125        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19126    }
19127
19128
19129    /// Startup file loader registers ids into the empty-default catalog.
19130    /// Feature-off dig2 probe stays stub. Never secrets on C2.
19131    #[test]
19132    fn network_allowlist_catalog_loads_from_temp_file() {
19133        let unique = std::time::SystemTime::now()
19134            .duration_since(std::time::UNIX_EPOCH)
19135            .unwrap()
19136            .as_nanos();
19137        let path = std::env::temp_dir().join(format!(
19138            "gate4agent-node-allowlist-config-{}-{unique}.txt",
19139            std::process::id(),
19140        ));
19141        std::fs::write(
19142            &path,
19143            "# opaque station allowlist ids\negress-default\nlab-egress\n",
19144        )
19145        .unwrap();
19146        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19147            .expect("temp catalog file must load");
19148        let _ = std::fs::remove_file(&path);
19149        assert_eq!(loaded.len(), 2);
19150        let (shared, defaults) = station_knobs_fixture();
19151        {
19152            let mut catalog = shared
19153                .network_allowlist_catalog
19154                .write()
19155                .unwrap_or_else(|poisoned| poisoned.into_inner());
19156            *catalog = loaded;
19157        }
19158        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19159        let spec = crate::protocol::SpawnSpec {
19160            target: crate::protocol::SpawnTarget {
19161                node_id: shared.node_id.clone(),
19162                workspace_id: WorkspaceId::new("primary").unwrap(),
19163                worktree_id: None,
19164            },
19165            profile_id: defaults.profile_id.clone(),
19166            expected_profile_revision: defaults.revision.clone(),
19167            overrides: crate::protocol::SpawnOverrides {
19168                network_allowlist: Some(allowlist.clone()),
19169                ..crate::protocol::SpawnOverrides::default()
19170            },
19171            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19172            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19173                "station-knobs-catalog-file",
19174            )
19175            .unwrap(),
19176            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19177        };
19178        let resolved = spec.resolve(&defaults).unwrap();
19179        let receipt = shared
19180            .resolve_environment_profile(&resolved)
19181            .expect("registered via file load")
19182            .expect("env profile present");
19183        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19184    }
19185
19186    /// Snapshot launch inventory echoes registered catalog ids (opaque).
19187    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
19188    #[test]
19189    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19190        let (shared, _defaults) = station_knobs_fixture();
19191        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19192        shared
19193            .network_allowlist_catalog
19194            .write()
19195            .unwrap_or_else(|poisoned| poisoned.into_inner())
19196            .insert_id(allowlist.clone())
19197            .unwrap();
19198        let snapshot = shared.snapshot();
19199        let inventory = snapshot
19200            .launch_inventory
19201            .expect("launch inventory present");
19202        let ids = inventory
19203            .network_allowlists
19204            .expect("non-empty catalog surfaces ids");
19205        assert_eq!(ids, vec![allowlist]);
19206    }
19207
19208    /// Track B: registered id with provider_native codex_network_access refuses
19209    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
19210    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
19211    #[test]
19212    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19213        let (shared, defaults) = station_knobs_fixture();
19214        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19215        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19216            id: allowlist.clone(),
19217            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19218                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19219                peer: "127.0.0.1:443".to_owned(),
19220            }],
19221            provider_native: Some(
19222                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19223                    codex_network_access: Some(true),
19224                },
19225            ),
19226        };
19227        shared
19228            .network_allowlist_catalog
19229            .write()
19230            .unwrap_or_else(|poisoned| poisoned.into_inner())
19231            .insert(entry)
19232            .unwrap();
19233        let spec = crate::protocol::SpawnSpec {
19234            target: crate::protocol::SpawnTarget {
19235                node_id: shared.node_id.clone(),
19236                workspace_id: WorkspaceId::new("primary").unwrap(),
19237                worktree_id: None,
19238            },
19239            profile_id: defaults.profile_id.clone(),
19240            expected_profile_revision: defaults.revision.clone(),
19241            overrides: crate::protocol::SpawnOverrides {
19242                network_allowlist: Some(allowlist),
19243                ..crate::protocol::SpawnOverrides::default()
19244            },
19245            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19246            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19247                "station-knobs-unsupported-mapping",
19248            )
19249            .unwrap(),
19250            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19251        };
19252        let resolved = spec.resolve(&defaults).unwrap();
19253        assert_eq!(resolved.provider.as_str(), "claude");
19254        let failure = shared
19255            .resolve_environment_profile(&resolved)
19256            .expect_err("claude + codex_network_access must refuse");
19257        assert_eq!(
19258            failure.code,
19259            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19260        );
19261    }
19262
19263    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
19264    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
19265    /// Never secrets on C2.
19266    #[test]
19267    fn resolve_codex_network_access_overlay_moderate_only() {
19268        let (shared, defaults) = station_knobs_fixture();
19269        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19270        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19271            id: allowlist.clone(),
19272            permits: Vec::new(),
19273            provider_native: Some(
19274                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19275                    codex_network_access: Some(true),
19276                },
19277            ),
19278        };
19279        shared
19280            .network_allowlist_catalog
19281            .write()
19282            .unwrap_or_else(|poisoned| poisoned.into_inner())
19283            .insert(entry)
19284            .unwrap();
19285
19286        let overlay = shared
19287            .resolve_codex_network_access_overlay_args(
19288                &agent("codex"),
19289                SessionMode::Pty,
19290                ApprovalLevel::Moderate,
19291                Some(&allowlist),
19292            )
19293            .expect("Moderate Codex maps network_access");
19294        assert_eq!(
19295            overlay,
19296            vec![
19297                std::ffi::OsString::from("-c"),
19298                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19299            ]
19300        );
19301
19302        let acp = shared
19303            .resolve_codex_network_access_overlay_args(
19304                &agent("codex"),
19305                SessionMode::Acp,
19306                ApprovalLevel::Moderate,
19307                Some(&allowlist),
19308            )
19309            .unwrap();
19310        assert_eq!(acp, overlay);
19311
19312        let inline = shared
19313            .resolve_codex_network_access_overlay_args(
19314                &agent("codex"),
19315                SessionMode::Inline,
19316                ApprovalLevel::Moderate,
19317                Some(&allowlist),
19318            )
19319            .unwrap_err();
19320        assert_eq!(
19321            inline.code,
19322            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19323        );
19324
19325        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19326            let spec = crate::protocol::SpawnSpec {
19327                target: crate::protocol::SpawnTarget {
19328                    node_id: shared.node_id.clone(),
19329                    workspace_id: WorkspaceId::new("primary").unwrap(),
19330                    worktree_id: None,
19331                },
19332                profile_id: defaults.profile_id.clone(),
19333                expected_profile_revision: defaults.revision.clone(),
19334                overrides: crate::protocol::SpawnOverrides {
19335                    provider: crate::protocol::SpawnOverride::Set {
19336                        value: agent("codex"),
19337                    },
19338                    approval_level: Some(level),
19339                    network_allowlist: Some(allowlist.clone()),
19340                    ..crate::protocol::SpawnOverrides::default()
19341                },
19342                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19343                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19344                    "codex-net-refuse-{}",
19345                    match level {
19346                        ApprovalLevel::ReadOnly => "ro",
19347                        ApprovalLevel::FullAuto => "fa",
19348                        _ => "other",
19349                    }
19350                ))
19351                .unwrap(),
19352                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19353            };
19354            let resolved = spec.resolve(&defaults).unwrap();
19355            let failure = shared
19356                .resolve_environment_profile(&resolved)
19357                .expect_err("RO/FullAuto must refuse");
19358            assert_eq!(
19359                failure.code,
19360                NodeFailureCode::UnsupportedNetworkAllowlistMapping
19361            );
19362        }
19363    }
19364
19365    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
19366    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
19367    /// Never secrets on C2; does not invent provider_native Claude keys.
19368    #[test]
19369    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19370        let (shared, _defaults) = station_knobs_fixture();
19371        let allowlist =
19372            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19373        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19374            id: allowlist.clone(),
19375            permits: vec![
19376                crate::network_allowlist_catalog::NetworkPermitSketch {
19377                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19378                    peer: "github.com:443".to_owned(),
19379                },
19380                crate::network_allowlist_catalog::NetworkPermitSketch {
19381                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19382                    peer: "*.npmjs.org".to_owned(),
19383                },
19384            ],
19385            provider_native: None,
19386        };
19387        shared
19388            .network_allowlist_catalog
19389            .write()
19390            .unwrap_or_else(|poisoned| poisoned.into_inner())
19391            .insert(entry)
19392            .unwrap();
19393
19394        let unique = std::time::SystemTime::now()
19395            .duration_since(std::time::UNIX_EPOCH)
19396            .unwrap()
19397            .as_nanos();
19398        let provider_home = std::env::temp_dir().join(format!(
19399            "gate4agent-claude-provider-home-{}-{unique}",
19400            std::process::id(),
19401        ));
19402        std::fs::create_dir_all(&provider_home).unwrap();
19403
19404        let overlay = shared
19405            .resolve_network_allowlist_overlay_args(
19406                &agent("claude"),
19407                SessionMode::Pty,
19408                ApprovalLevel::Moderate,
19409                Some(&allowlist),
19410                Some(provider_home.as_path()),
19411            )
19412            .expect("Claude PTY settings overlay on unix");
19413        let settings_path = provider_home.join(
19414            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19415        );
19416        assert_eq!(
19417            overlay,
19418            vec![
19419                std::ffi::OsString::from("--settings"),
19420                std::ffi::OsString::from(settings_path.as_os_str()),
19421            ]
19422        );
19423        let body = std::fs::read_to_string(&settings_path).unwrap();
19424        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19425        assert_eq!(value["sandbox"]["enabled"], true);
19426        assert_eq!(
19427            value["sandbox"]["network"]["allowedDomains"],
19428            serde_json::json!(["github.com", "*.npmjs.org"])
19429        );
19430        assert!(settings_path.starts_with(&provider_home));
19431
19432        let acp = shared
19433            .resolve_network_allowlist_overlay_args(
19434                &agent("claude"),
19435                SessionMode::Acp,
19436                ApprovalLevel::Moderate,
19437                Some(&allowlist),
19438                Some(provider_home.as_path()),
19439            )
19440            .expect_err("Claude ACP must refuse settings overlay");
19441        assert_eq!(
19442            acp.code,
19443            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19444        );
19445
19446        let no_home = shared
19447            .resolve_network_allowlist_overlay_args(
19448                &agent("claude"),
19449                SessionMode::Pty,
19450                ApprovalLevel::Moderate,
19451                Some(&allowlist),
19452                None,
19453            )
19454            .expect_err("Claude overlay requires ProviderHome");
19455        assert_eq!(
19456            no_home.code,
19457            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19458        );
19459
19460        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19461        shared
19462            .network_allowlist_catalog
19463            .write()
19464            .unwrap_or_else(|poisoned| poisoned.into_inner())
19465            .insert_id(bare.clone())
19466            .unwrap();
19467        let empty = shared
19468            .resolve_network_allowlist_overlay_args(
19469                &agent("claude"),
19470                SessionMode::Pty,
19471                ApprovalLevel::Moderate,
19472                Some(&bare),
19473                Some(provider_home.as_path()),
19474            )
19475            .unwrap();
19476        assert!(empty.is_empty());
19477
19478        let _ = std::fs::remove_dir_all(&provider_home);
19479    }
19480
19481    /// Kimi / Grok with permits stay without invented argv overlays.
19482    #[test]
19483    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19484        let (shared, _defaults) = station_knobs_fixture();
19485        let allowlist =
19486            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19487        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19488            id: allowlist.clone(),
19489            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19490                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19491                peer: "example.com:443".to_owned(),
19492            }],
19493            provider_native: None,
19494        };
19495        shared
19496            .network_allowlist_catalog
19497            .write()
19498            .unwrap_or_else(|poisoned| poisoned.into_inner())
19499            .insert(entry)
19500            .unwrap();
19501        for provider in ["kimi", "grok"] {
19502            let overlay = shared
19503                .resolve_network_allowlist_overlay_args(
19504                    &agent(provider),
19505                    SessionMode::Pty,
19506                    ApprovalLevel::Moderate,
19507                    Some(&allowlist),
19508                    None,
19509                )
19510                .unwrap();
19511            assert!(
19512                overlay.is_empty(),
19513                "{provider} must not invent network argv from permits"
19514            );
19515        }
19516    }
19517
19518    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
19519    #[test]
19520    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19521        let unique = std::time::SystemTime::now()
19522            .duration_since(std::time::UNIX_EPOCH)
19523            .unwrap()
19524            .as_nanos();
19525        let path = std::env::temp_dir().join(format!(
19526            "gate4agent-node-allowlist-json-{}-{unique}.json",
19527            std::process::id(),
19528        ));
19529        std::fs::write(
19530            &path,
19531            r#"{
19532              "schema_version": 2,
19533              "entries": [
19534                {
19535                  "id": "egress-default",
19536                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19537                },
19538                { "id": "lab-egress" }
19539              ]
19540            }"#,
19541        )
19542        .unwrap();
19543        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19544            .expect("json v2 catalog must load");
19545        let _ = std::fs::remove_file(&path);
19546        assert_eq!(loaded.len(), 2);
19547        let entry = loaded
19548            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19549            .unwrap();
19550        assert_eq!(entry.permits.len(), 1);
19551        let (shared, defaults) = station_knobs_fixture();
19552        {
19553            let mut catalog = shared
19554                .network_allowlist_catalog
19555                .write()
19556                .unwrap_or_else(|poisoned| poisoned.into_inner());
19557            *catalog = loaded;
19558        }
19559        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19560        let spec = crate::protocol::SpawnSpec {
19561            target: crate::protocol::SpawnTarget {
19562                node_id: shared.node_id.clone(),
19563                workspace_id: WorkspaceId::new("primary").unwrap(),
19564                worktree_id: None,
19565            },
19566            profile_id: defaults.profile_id.clone(),
19567            expected_profile_revision: defaults.revision.clone(),
19568            overrides: crate::protocol::SpawnOverrides {
19569                network_allowlist: Some(allowlist.clone()),
19570                ..crate::protocol::SpawnOverrides::default()
19571            },
19572            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19573            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19574                "station-knobs-catalog-json",
19575            )
19576            .unwrap(),
19577            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19578        };
19579        let resolved = spec.resolve(&defaults).unwrap();
19580        let receipt = shared
19581            .resolve_environment_profile(&resolved)
19582            .expect("permits-only entry ok for claude")
19583            .expect("env profile present");
19584        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19585        let inventory = shared
19586            .snapshot()
19587            .launch_inventory
19588            .expect("launch inventory present");
19589        let ids = inventory.network_allowlists.expect("ids surface");
19590        assert_eq!(
19591            ids,
19592            vec![
19593                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19594                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19595            ]
19596        );
19597    }
19598
19599
19600}
19601
19602
19603#[cfg(test)]
19604mod workspace_environment_collision_tests {
19605    use super::*;
19606    use crate::session_environment::{
19607        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19608    };
19609    use gate4agent_catalog::EnvMutation;
19610    use gate4agent_runtime_native::{
19611        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19612        NativeLaunchProfileId,
19613    };
19614    use std::ffi::OsString;
19615    use std::sync::Arc;
19616
19617    struct EmptyEnvironmentResolver;
19618
19619    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19620        fn resolve_child_environment(
19621            &self,
19622        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19623            Ok(vec![EnvMutation {
19624                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19625                value: None,
19626            }])
19627        }
19628    }
19629
19630    fn agent(value: &str) -> AgentId {
19631        AgentId::new(value).unwrap()
19632    }
19633
19634    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
19635    fn claude_config_dir_collision_fixture() -> (
19636        NodeShared,
19637        ResolvedEnvironmentProfileReceipt,
19638        ResolvedEnvironmentProfileReceipt,
19639    ) {
19640        let catalog = active_registry().unwrap();
19641        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19642        let control = runtime.native_launch_profile_control();
19643        let workspace = WorkspaceConfig::new(
19644            WorkspaceId::new("primary").unwrap(),
19645            std::env::current_dir().unwrap(),
19646        )
19647        .unwrap();
19648        let shared = NodeShared::new_with_incarnation(
19649            handle,
19650            "fixture-token".to_owned(),
19651            NodeId::new("node-env-collision-test").unwrap(),
19652            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19653            vec![workspace],
19654            vec![agent("claude")],
19655            ProviderRuntimeStatuses::default(),
19656            None,
19657            Vec::new(),
19658            Vec::new(),
19659            SpawnProfileRegistry::default(),
19660            Some(control),
19661            None,
19662            None,
19663            None,
19664            Vec::new(),
19665            Vec::new(),
19666            Vec::new(),
19667            Vec::new(),
19668            None,
19669            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19670        );
19671
19672        let mut receipts = Vec::new();
19673        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19674            let profile_id =
19675                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19676            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19677                "claude-config-dir-{suffix}-r1"
19678            ))
19679            .unwrap();
19680            let materialization = NodeSessionMaterializationProfile::new(
19681                Vec::new(),
19682                vec![NodeSessionPathBinding::new(
19683                    "CLAUDE_CONFIG_DIR",
19684                    NodeSessionPathClass::ProviderHome,
19685                )
19686                .unwrap()],
19687                Vec::new(),
19688            )
19689            .unwrap();
19690            let node_profile = NodeEnvironmentProfile::new_with_materialization(
19691                profile_id.clone(),
19692                profile_revision.clone(),
19693                agent("claude"),
19694                [NativeLaunchProfile::new(
19695                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19696                        .unwrap(),
19697                    agent("claude"),
19698                    TransportKind::Pty,
19699                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19700                    Arc::new(EmptyEnvironmentResolver),
19701                )
19702                .unwrap()],
19703                Some(materialization),
19704            )
19705            .unwrap();
19706            let (binding, native_profiles, materialization) = node_profile.into_parts();
19707            for native_profile in native_profiles {
19708                runtime.upsert_native_launch_profile(native_profile).unwrap();
19709            }
19710            shared
19711                .environment_profiles
19712                .write()
19713                .unwrap_or_else(|poisoned| poisoned.into_inner())
19714                .insert(binding.id.clone(), binding);
19715            shared
19716                .environment_materialization_profiles
19717                .write()
19718                .unwrap_or_else(|poisoned| poisoned.into_inner())
19719                .insert(profile_id.clone(), materialization.unwrap());
19720            receipts.push(ResolvedEnvironmentProfileReceipt {
19721                profile_id,
19722                profile_revision,
19723                network_allowlist: None,
19724                browser_profile_id: None,
19725            });
19726        }
19727        let profile_b = receipts.pop().unwrap();
19728        let profile_a = receipts.pop().unwrap();
19729        (shared, profile_a, profile_b)
19730    }
19731
19732    #[test]
19733    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19734        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19735        let primary = WorkspaceId::new("primary").unwrap();
19736        let worktree = WorkspaceId::new("worktree-b").unwrap();
19737        let address = SessionAddress {
19738            workspace_id: primary.clone(),
19739            session: SessionKey {
19740                instance_id: AgentInstanceId(41),
19741                generation: SessionGeneration(1),
19742            },
19743        };
19744        shared.bind_session_with_policy(
19745            &address,
19746            ProviderRuntimePolicy::raw_pty(),
19747            Some(profile_a.clone()),
19748        );
19749
19750        let refused = shared
19751            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19752            .unwrap_err();
19753        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19754
19755        // Some vs None on the same cwd is also a collision.
19756        let refused_none = shared
19757            .ensure_workspace_environment_profile_compatible(&primary, None)
19758            .unwrap_err();
19759        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19760
19761        // Same profile on the same workspace remains allowed.
19762        shared
19763            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19764            .unwrap();
19765
19766        // Distinct workspace id (managed-worktree parallel path) allows profile B.
19767        shared
19768            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19769            .unwrap();
19770    }
19771
19772    #[tokio::test]
19773    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19774        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19775        let primary = WorkspaceId::new("primary").unwrap();
19776        let address = SessionAddress {
19777            workspace_id: primary.clone(),
19778            session: SessionKey {
19779                instance_id: AgentInstanceId(41),
19780                generation: SessionGeneration(1),
19781            },
19782        };
19783        shared.bind_session_with_policy(
19784            &address,
19785            ProviderRuntimePolicy::raw_pty(),
19786            Some(profile_a),
19787        );
19788
19789        let failure = shared
19790            .spawn_session_with_deadline(
19791                primary,
19792                agent("claude"),
19793                SessionMode::Pty,
19794                gate4agent_types::TerminalSize {
19795                    rows: 24,
19796                    columns: 80,
19797                },
19798                None,
19799                Some(profile_b),
19800                None,
19801                None,
19802                None,
19803                None,
19804                SpawnRecordPolicy::ProviderIdentityOnly,
19805                Some(Instant::now() + Duration::from_secs(30)),
19806                &[],
19807                None,
19808                ApprovalLevel::default(),
19809                None,
19810                None,
19811            )
19812            .await
19813            .unwrap_err();
19814        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19815        // Guard runs before instance id mint / bind.
19816        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19817        assert!(shared.handle.snapshot().sessions.is_empty());
19818    }
19819}
19820
19821#[cfg(test)]
19822mod spawn_characterization_tests {
19823    use super::*;
19824    use crate::session_environment::{
19825        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19826        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19827        NodeSessionPathClass,
19828    };
19829    use gate4agent_catalog::EnvMutation;
19830    use gate4agent_runtime_native::{
19831        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19832        NativeLaunchProfile, NativeLaunchProfileId,
19833    };
19834    use std::ffi::OsString;
19835    use std::sync::Arc;
19836
19837    struct EmptyEnvironmentResolver;
19838
19839    struct FixtureSecretResolver {
19840        deny: Arc<AtomicBool>,
19841    }
19842
19843    impl NodeSecretResolver for FixtureSecretResolver {
19844        fn resolve(
19845            &self,
19846            _reference: &NodeSecretReference,
19847        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19848            if self.deny.load(Ordering::Acquire) {
19849                Err(NodeSecretResolveError::Denied)
19850            } else {
19851                NodeSecretValue::text("fixture-secret")
19852                    .map_err(|_| NodeSecretResolveError::Unavailable)
19853            }
19854        }
19855    }
19856
19857    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19858        fn resolve_child_environment(
19859            &self,
19860        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19861            Ok(vec![EnvMutation {
19862                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19863                value: None,
19864            }])
19865        }
19866    }
19867
19868    fn agent(value: &str) -> AgentId {
19869        AgentId::new(value).unwrap()
19870    }
19871
19872    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19873        let unique = std::time::SystemTime::now()
19874            .duration_since(std::time::UNIX_EPOCH)
19875            .unwrap()
19876            .as_nanos();
19877        std::env::temp_dir().join(format!(
19878            "gate4agent-node-{label}-{}-{unique}",
19879            std::process::id(),
19880        ))
19881    }
19882
19883    fn materialization_test_shared(
19884        deny_secret: bool,
19885    ) -> (
19886        NodeShared,
19887        NativeRuntime,
19888        ResolvedEnvironmentProfileReceipt,
19889        PathBuf,
19890        Arc<AtomicBool>,
19891    ) {
19892        let workspace = WorkspaceConfig::new(
19893            WorkspaceId::new("primary").unwrap(),
19894            std::env::current_dir().unwrap(),
19895        )
19896        .unwrap();
19897        materialization_test_shared_with_workspace(deny_secret, workspace)
19898    }
19899
19900    fn materialization_test_shared_with_workspace(
19901        deny_secret: bool,
19902        workspace: WorkspaceConfig,
19903    ) -> (
19904        NodeShared,
19905        NativeRuntime,
19906        ResolvedEnvironmentProfileReceipt,
19907        PathBuf,
19908        Arc<AtomicBool>,
19909    ) {
19910        let root = temporary_workspace_root(if deny_secret {
19911            "materialization-denied"
19912        } else {
19913            "materialization-ready"
19914        });
19915        std::fs::create_dir_all(&root).unwrap();
19916        let materialization_root = root.join("session-environments");
19917        let deny = Arc::new(AtomicBool::new(deny_secret));
19918        let resolver = Arc::new(FixtureSecretResolver {
19919            deny: Arc::clone(&deny),
19920        });
19921        let materializer = SessionEnvironmentMaterializer::new(
19922            materialization_root,
19923            resolver,
19924        )
19925        .unwrap();
19926        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19927        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19928            "materialized-claude-r1",
19929        )
19930        .unwrap();
19931        let materialization = NodeSessionMaterializationProfile::new(
19932            vec![NodeSessionEnvironmentMutation::SetSecret {
19933                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19934                reference: NodeSecretReference::new("fixture-token").unwrap(),
19935            }],
19936            vec![NodeSessionPathBinding::new(
19937                "GATE4AGENT_PROVIDER_HOME",
19938                NodeSessionPathClass::ProviderHome,
19939            )
19940            .unwrap()],
19941            vec![NodeSessionFile::secret(
19942                NodeSessionPathClass::Config,
19943                "auth/token",
19944                NodeSecretReference::new("fixture-token").unwrap(),
19945            )
19946            .unwrap()],
19947        )
19948        .unwrap();
19949        let catalog = active_registry().unwrap();
19950        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19951        let control = runtime.native_launch_profile_control();
19952        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19953            profile_id.clone(),
19954            profile_revision.clone(),
19955            agent("claude"),
19956            [NativeLaunchProfile::new(
19957                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19958                agent("claude"),
19959                TransportKind::Pty,
19960                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19961                Arc::new(EmptyEnvironmentResolver),
19962            )
19963            .unwrap()],
19964            Some(materialization),
19965        )
19966        .unwrap();
19967        let (binding, native_profiles, materialization) = node_profile.into_parts();
19968        for native_profile in native_profiles {
19969            runtime.upsert_native_launch_profile(native_profile).unwrap();
19970        }
19971        let shared = NodeShared::new_with_incarnation(
19972            handle,
19973            "fixture-token".to_owned(),
19974            NodeId::new("node-materialization-test").unwrap(),
19975            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19976            vec![workspace],
19977            vec![agent("claude")],
19978            ProviderRuntimeStatuses::default(),
19979            None,
19980            Vec::new(),
19981            Vec::new(),
19982            SpawnProfileRegistry::default(),
19983            Some(control),
19984            None,
19985            Some(materializer),
19986            None,
19987            Vec::new(),
19988            Vec::new(),
19989            Vec::new(),
19990            Vec::new(),
19991            None,
19992            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19993        );
19994        shared
19995            .environment_profiles
19996            .write()
19997            .unwrap_or_else(|poisoned| poisoned.into_inner())
19998            .insert(binding.id.clone(), binding);
19999        shared
20000            .environment_materialization_profiles
20001            .write()
20002            .unwrap_or_else(|poisoned| poisoned.into_inner())
20003            .insert(profile_id.clone(), materialization.unwrap());
20004        (
20005            shared,
20006            runtime,
20007            ResolvedEnvironmentProfileReceipt {
20008                profile_id,
20009                profile_revision,
20010                network_allowlist: None,
20011                browser_profile_id: None,
20012            },
20013            root,
20014            deny,
20015        )
20016    }
20017
20018
20019    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
20020
20021    async fn characterization_spawn(
20022        shared: &NodeShared,
20023        mode: SessionMode,
20024        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20025        required: &[ProviderRuntimeCapability],
20026        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20027    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20028        shared
20029            .spawn_session_with_deadline(
20030                WorkspaceId::new("primary").unwrap(),
20031                agent("claude"),
20032                mode,
20033                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20034                None,
20035                environment_profile,
20036                None,
20037                None,
20038                None,
20039                Some(ProviderRuntimePolicy::raw_pty()),
20040                SpawnRecordPolicy::ProviderIdentityOnly,
20041                Some(Instant::now() + Duration::from_secs(30)),
20042                required,
20043                harness_mcp,
20044                ApprovalLevel::default(),
20045                None,
20046                None,
20047            )
20048            .await
20049    }
20050
20051    fn assert_no_session_state(shared: &NodeShared) {
20052        assert!(shared.handle.snapshot().sessions.is_empty());
20053        assert!(shared
20054            .session_bindings
20055            .lock()
20056            .unwrap_or_else(|poisoned| poisoned.into_inner())
20057            .is_empty());
20058        assert!(shared
20059            .materializations
20060            .lock()
20061            .unwrap_or_else(|poisoned| poisoned.into_inner())
20062            .is_empty());
20063    }
20064
20065    #[tokio::test]
20066    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20067        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20068        let error = characterization_spawn(
20069            &shared,
20070            SessionMode::Pty,
20071            None,
20072            &[ProviderRuntimeCapability::SemanticReadiness],
20073            None,
20074        )
20075        .await
20076        .unwrap_err();
20077        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20078        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20079        assert_no_session_state(&shared);
20080        drop(shared);
20081        drop(runtime);
20082        std::fs::remove_dir_all(root).unwrap();
20083    }
20084
20085    #[tokio::test]
20086    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20087        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20088        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20089            .await
20090            .unwrap_err();
20091        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20092        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20093        assert_no_session_state(&shared);
20094        drop(shared);
20095        drop(runtime);
20096        std::fs::remove_dir_all(root).unwrap();
20097    }
20098
20099    #[test]
20100    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20101        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20102        let address = SessionAddress {
20103            workspace_id: WorkspaceId::new("primary").unwrap(),
20104            session: SessionKey {
20105                instance_id: AgentInstanceId(77),
20106                generation: SessionGeneration(1),
20107            },
20108        };
20109        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20110        let removed = shared.remove_binding(&address).expect("binding removed");
20111        assert!(removed.environment_profile.is_some());
20112        assert!(shared.remove_binding(&address).is_none());
20113        assert_no_session_state(&shared);
20114        drop(shared);
20115        drop(runtime);
20116        std::fs::remove_dir_all(root).unwrap();
20117    }
20118
20119    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20120        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20121        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20122        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20123            .expect("review current test executable as fixture helper");
20124        let registry = HarnessMcpProxyRegistry::new(helper);
20125        let spec = crate::protocol::SpawnSpec {
20126            target: crate::protocol::SpawnTarget {
20127                node_id: NodeId::new("node-characterization").unwrap(),
20128                workspace_id: WorkspaceId::new("primary").unwrap(),
20129                worktree_id: None,
20130            },
20131            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20132            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20133            overrides: crate::protocol::SpawnOverrides::default(),
20134            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20135            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20136            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20137        };
20138        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20139        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20140        let launch = HarnessMcpLaunchV1 {
20141            server_name: "fixture-harness".to_owned(),
20142            args: vec!["--session-proxy".to_owned()],
20143            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20144            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20145            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20146            trace: None,
20147            scrub_env: Vec::new(),
20148        };
20149        let expires = unix_time_ms().saturating_add(30_000);
20150        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20151        registry
20152            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20153            .unwrap()
20154    }
20155
20156    #[tokio::test]
20157    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20158        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20159        let prepared = characterization_prepared_harness("claude").await;
20160        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20161            .await
20162            .unwrap_err();
20163        // Inline is stopped by runtime admission before the harness check runs.
20164        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20165        let mismatched = characterization_prepared_harness("codex").await;
20166        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20167            .await
20168            .unwrap_err();
20169        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20170        assert_no_session_state(&shared);
20171        drop(shared);
20172        drop(runtime);
20173        std::fs::remove_dir_all(root).unwrap();
20174    }
20175
20176    #[tokio::test]
20177    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20178        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20179        shared.native_launch_profile_control = None;
20180        let prepared = characterization_prepared_harness("claude").await;
20181        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20182            .await
20183            .unwrap_err();
20184        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20185        assert_no_session_state(&shared);
20186        drop(shared);
20187        drop(runtime);
20188        std::fs::remove_dir_all(root).unwrap();
20189    }
20190}
20191
20192#[cfg(all(test, windows))]
20193mod tests {
20194    use super::*;
20195    use gate4agent_types::ProviderActivity;
20196    use crate::session_environment::{
20197        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20198        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20199        NodeSessionPathClass,
20200    };
20201    use gate4agent_catalog::EnvMutation;
20202    use gate4agent_runtime_native::{
20203        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20204        NativeLaunchProfile, NativeLaunchProfileId,
20205    };
20206    use std::ffi::OsString;
20207    use std::sync::Arc;
20208
20209    struct EmptyEnvironmentResolver;
20210
20211    struct FixtureSecretResolver {
20212        deny: Arc<AtomicBool>,
20213    }
20214
20215    impl NodeSecretResolver for FixtureSecretResolver {
20216        fn resolve(
20217            &self,
20218            _reference: &NodeSecretReference,
20219        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20220            if self.deny.load(Ordering::Acquire) {
20221                Err(NodeSecretResolveError::Denied)
20222            } else {
20223                NodeSecretValue::text("fixture-secret")
20224                    .map_err(|_| NodeSecretResolveError::Unavailable)
20225            }
20226        }
20227    }
20228
20229    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20230        fn resolve_child_environment(
20231            &self,
20232        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20233            Ok(vec![EnvMutation {
20234                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20235                value: None,
20236            }])
20237        }
20238    }
20239
20240    fn agent(value: &str) -> AgentId {
20241        AgentId::new(value).unwrap()
20242    }
20243
20244    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20245        let catalog = active_registry().unwrap();
20246        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20247        let workspace = WorkspaceConfig::new(
20248            WorkspaceId::new("primary").unwrap(),
20249            std::env::current_dir().unwrap(),
20250        )
20251        .unwrap()
20252        .with_worktree_service_mode(mode);
20253        NodeShared::new(
20254            handle,
20255            "fixture-token".to_owned(),
20256            NodeId::new("node-terminal-test").unwrap(),
20257            vec![workspace],
20258            vec![agent("claude")],
20259        )
20260    }
20261
20262    fn terminal_test_shared() -> NodeShared {
20263        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20264    }
20265
20266    #[cfg(feature = "fixture")]
20267    #[test]
20268    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20269        let probe = SpawnManagedWorktreeV2FailureProbe::default();
20270        assert_eq!(probe.latest_code(), None);
20271
20272        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20273        assert_eq!(
20274            probe.latest_code(),
20275            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20276        );
20277
20278        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20279        assert_eq!(
20280            probe.latest_code(),
20281            Some(NodeFailureCode::SpawnDeadlineExceeded),
20282        );
20283
20284        probe.record(None);
20285        assert_eq!(probe.latest_code(), None);
20286    }
20287
20288    #[test]
20289    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20290        let source = temporary_workspace_root("launch-inventory-source");
20291        let allocation = temporary_workspace_root("launch-inventory-allocation");
20292        std::fs::create_dir_all(&source).unwrap();
20293        std::fs::create_dir_all(&allocation).unwrap();
20294        let workspace_id = WorkspaceId::new("primary").unwrap();
20295        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20296            .unwrap()
20297            .with_worktree_service_mode(WorktreeServiceMode::Managed);
20298        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20299            workspace = workspace
20300                .with_managed_worktree_profile(
20301                    ManagedWorktreeProfile::new(
20302                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20303                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20304                        &allocation,
20305                        "gate4agent",
20306                        "HEAD",
20307                        ManagedWorktreeRetention::RemoveWhenReleased,
20308                    )
20309                    .unwrap(),
20310                )
20311                .unwrap();
20312        }
20313        let overflow = workspace.clone().with_managed_worktree_profile(
20314            ManagedWorktreeProfile::new(
20315                WorktreeProfileId::new("overflow").unwrap(),
20316                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20317                &allocation,
20318                "gate4agent",
20319                "HEAD",
20320                ManagedWorktreeRetention::Retain,
20321            )
20322            .unwrap(),
20323        );
20324        assert!(matches!(
20325            overflow,
20326            Err(NodeServerError::ManagedWorktreeProfileCapacity {
20327                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20328                ..
20329            }),
20330        ));
20331
20332        let catalog = active_registry().unwrap();
20333        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20334        let shared = NodeShared::new(
20335            handle,
20336            "fixture-token".to_owned(),
20337            NodeId::new("node-launch-inventory").unwrap(),
20338            vec![workspace],
20339            vec![agent("claude")],
20340        );
20341        let snapshot = shared.snapshot();
20342        let launch = snapshot.launch_inventory.as_ref().unwrap();
20343        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20344        assert!(launch.bundles.as_ref().unwrap().is_empty());
20345        let profiles = snapshot.workspaces[0]
20346            .managed_worktree_profiles
20347            .as_ref()
20348            .unwrap();
20349        assert_eq!(
20350            snapshot.workspaces[0].worktree_service_mode,
20351            Some(WorktreeServiceMode::Managed),
20352        );
20353        assert_eq!(
20354            profiles.profiles.len(),
20355            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20356        );
20357        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20358        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20359            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20360        }
20361        let legacy = snapshot_for_wire(
20362            &shared,
20363            true,
20364            true,
20365            false,
20366            false,
20367            true,
20368            false,
20369            true,
20370            false,
20371            true,
20372        );
20373        assert!(legacy.launch_inventory.is_none());
20374        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20375
20376        drop(shared);
20377        std::fs::remove_dir_all(source).unwrap();
20378        std::fs::remove_dir_all(allocation).unwrap();
20379    }
20380
20381    #[test]
20382    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20383        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20384            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20385        }));
20386        let source = gate4agent_types::ProviderSource {
20387            family: AdapterFamily::PtySemantic,
20388            binding: AdapterBinding::new(
20389                gate4agent_types::AdapterId::new("claude").unwrap(),
20390                "fixture/v1",
20391                gate4agent_types::AdapterVerification::SyntheticFixture,
20392            )
20393            .unwrap(),
20394        };
20395        let provider = ProviderSnapshot {
20396            sequence: 17,
20397            session: Some(ProviderSessionIdentity {
20398                key: ProviderSessionKey::SessionId,
20399                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20400                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20401            }),
20402            model: Some("PRIVATE_MODEL".to_owned()),
20403            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20404            completed_turns: 3,
20405            usage: gate4agent_types::TokenUsage {
20406                input_tokens: 10,
20407                output_tokens: 20,
20408                cache_read_tokens: 30,
20409                cache_write_tokens: 40,
20410                reasoning_tokens: 50,
20411                context_window: Some(200_000),
20412            },
20413            lead_activity: ProviderActivity::WaitingForInput,
20414            activity: ProviderActivity::WaitingForInput,
20415            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20416            active_tools: (0..9)
20417                .map(|index| gate4agent_types::ActiveProviderTool {
20418                    id: format!("private-tool-id-{index}"),
20419                    name: if index == 0 {
20420                        format!("  Read\u{0000}{}", "x".repeat(80))
20421                    } else {
20422                        format!("Tool-{index}")
20423                    },
20424                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20425                })
20426                .collect(),
20427            interactions: vec![gate4agent_types::ProviderInteraction {
20428                id: gate4agent_types::ProviderInteractionId(17),
20429                source: source.clone(),
20430                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20431                interaction_kind: ProviderInteractionKind::Approval,
20432                tool_name: " Approve\u{0007}Tool ".to_owned(),
20433                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20434                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20435                resume_lead_activity: Some(ProviderActivity::Working),
20436                status: ProviderInteractionStatus::Pending,
20437            }],
20438            subagents: vec![gate4agent_types::ProviderSubagent {
20439                source: source.clone(),
20440                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20441                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20442                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20443            }],
20444            sources: vec![gate4agent_types::ProviderSourceCursor {
20445                source,
20446                sequence: 17,
20447                gap_count: 2,
20448                stale: true,
20449            }],
20450            last_event: Some(ProviderEvent::Error {
20451                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20452            }),
20453            gap_count: 2,
20454            stale: true,
20455        };
20456        let address = terminal_address(3);
20457        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20458        assert_eq!(entry.address, address);
20459        assert_eq!(entry.progress.provider_sequence, 17);
20460        assert_eq!(entry.progress.active_tool_count, 9);
20461        assert_eq!(
20462            entry.progress.active_tool_labels.len(),
20463            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20464        );
20465        assert_eq!(entry.progress.subagent_count, 1);
20466        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20467        assert!(entry.progress.truncated);
20468        let encoded = serde_json::to_string(&entry).unwrap();
20469        for forbidden in [
20470            "PRIVATE_PROVIDER_SESSION",
20471            "PRIVATE_PROVIDER_PATH",
20472            "PRIVATE_MODEL",
20473            "PRIVATE_TOOL_INVENTORY",
20474            "PRIVATE_CURRENT_PROMPT",
20475            "PRIVATE_TOOL_INPUT_JSON",
20476            "PRIVATE_PROVIDER_REQUEST",
20477            "PRIVATE_INTERACTION_PROMPT",
20478            "PRIVATE_INTERACTION_AGENT",
20479            "PRIVATE_SUBAGENT_ID",
20480            "PRIVATE_SUBAGENT_TYPE",
20481            "PRIVATE_SUBAGENT_DESCRIPTION",
20482            "PRIVATE_PROVIDER_ERROR",
20483            "input_json",
20484            "current_prompt",
20485            "session_id",
20486        ] {
20487            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20488        }
20489
20490        let snapshot = NodeSnapshot {
20491            node_id: NodeId::new("node-progress-test").unwrap(),
20492            enabled_providers: vec![agent("claude")],
20493            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20494            workspaces: Vec::new(),
20495            session_records: Vec::new(),
20496            managed_worktrees: Vec::new(),
20497            launch_inventory: None,
20498            agent_progress: vec![entry],
20499        };
20500        let mut legacy_reply = ResponseEnvelope {
20501            request_id: 1,
20502            result: Ok(NodeResponse::Snapshot {
20503                event_sequence: 0,
20504                controller: None,
20505                snapshot: snapshot.clone(),
20506            }),
20507        };
20508        project_response_without_agent_progress(&mut legacy_reply);
20509        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20510            panic!("agent progress projection changed response kind");
20511        };
20512        assert!(legacy.agent_progress.is_empty());
20513        assert_eq!(snapshot.agent_progress.len(), 1);
20514    }
20515
20516    fn terminal_address(generation: u64) -> SessionAddress {
20517        SessionAddress {
20518            workspace_id: WorkspaceId::new("primary").unwrap(),
20519            session: SessionKey {
20520                instance_id: AgentInstanceId(41),
20521                generation: SessionGeneration(generation),
20522            },
20523        }
20524    }
20525
20526    fn spawn_spec_fixture() -> SpawnSpec {
20527        SpawnSpec {
20528            target: crate::protocol::SpawnTarget {
20529                node_id: NodeId::new("node-terminal-test").unwrap(),
20530                workspace_id: WorkspaceId::new("primary").unwrap(),
20531                worktree_id: None,
20532            },
20533            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20534            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20535                .unwrap(),
20536            overrides: crate::protocol::SpawnOverrides::default(),
20537            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20538            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20539            required_capabilities: SpawnRequiredCapabilities::default(),
20540        }
20541    }
20542
20543    fn environment_profile_test_shared(
20544    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20545        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20546        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20547            "local-claude-r1",
20548        )
20549        .unwrap();
20550        let spawn_profiles = SpawnProfileRegistry::new([
20551            crate::protocol::SpawnProfileDefaults {
20552                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20553                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20554                provider: agent("claude"),
20555                mode: SessionMode::Pty,
20556                terminal_size: gate4agent_types::TerminalSize {
20557                    rows: 24,
20558                    columns: 80,
20559                },
20560                prompt: None,
20561                bundle_id: None,
20562                context_id: None,
20563                environment_profile_id: Some(profile_id.clone()),
20564            },
20565        ])
20566        .unwrap();
20567        let catalog = active_registry().unwrap();
20568        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20569        let control = runtime.native_launch_profile_control();
20570        let node_profile = NodeEnvironmentProfile::new(
20571            profile_id.clone(),
20572            profile_revision.clone(),
20573            agent("claude"),
20574            [NativeLaunchProfile::new(
20575                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20576                agent("claude"),
20577                TransportKind::Pty,
20578                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20579                Arc::new(EmptyEnvironmentResolver),
20580            )
20581            .unwrap()],
20582        )
20583        .unwrap();
20584        let (binding, native_profiles, _materialization) = node_profile.into_parts();
20585        for native_profile in native_profiles {
20586            runtime.upsert_native_launch_profile(native_profile).unwrap();
20587        }
20588        let workspace = WorkspaceConfig::new(
20589            WorkspaceId::new("primary").unwrap(),
20590            std::env::current_dir().unwrap(),
20591        )
20592        .unwrap();
20593        let shared = NodeShared::new_with_incarnation(
20594            handle,
20595            "fixture-token".to_owned(),
20596            NodeId::new("node-terminal-test").unwrap(),
20597            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20598            vec![workspace],
20599            vec![agent("claude")],
20600            ProviderRuntimeStatuses::default(),
20601            None,
20602            Vec::new(),
20603            Vec::new(),
20604            spawn_profiles,
20605            Some(control),
20606            None,
20607            None,
20608            None,
20609            Vec::new(),
20610            Vec::new(),
20611            Vec::new(),
20612            Vec::new(),
20613            None,
20614            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20615        );
20616        shared
20617            .environment_profiles
20618            .write()
20619            .unwrap_or_else(|poisoned| poisoned.into_inner())
20620            .insert(binding.id.clone(), binding);
20621        (
20622            shared,
20623            runtime,
20624            ResolvedEnvironmentProfileReceipt {
20625                profile_id,
20626                profile_revision,
20627                network_allowlist: None,
20628                browser_profile_id: None,
20629            },
20630        )
20631    }
20632
20633    fn materialization_test_shared(
20634        deny_secret: bool,
20635    ) -> (
20636        NodeShared,
20637        NativeRuntime,
20638        ResolvedEnvironmentProfileReceipt,
20639        PathBuf,
20640        Arc<AtomicBool>,
20641    ) {
20642        let workspace = WorkspaceConfig::new(
20643            WorkspaceId::new("primary").unwrap(),
20644            std::env::current_dir().unwrap(),
20645        )
20646        .unwrap();
20647        materialization_test_shared_with_workspace(deny_secret, workspace)
20648    }
20649
20650    fn materialization_test_shared_with_workspace(
20651        deny_secret: bool,
20652        workspace: WorkspaceConfig,
20653    ) -> (
20654        NodeShared,
20655        NativeRuntime,
20656        ResolvedEnvironmentProfileReceipt,
20657        PathBuf,
20658        Arc<AtomicBool>,
20659    ) {
20660        let root = temporary_workspace_root(if deny_secret {
20661            "materialization-denied"
20662        } else {
20663            "materialization-ready"
20664        });
20665        std::fs::create_dir_all(&root).unwrap();
20666        let materialization_root = root.join("session-environments");
20667        let deny = Arc::new(AtomicBool::new(deny_secret));
20668        let resolver = Arc::new(FixtureSecretResolver {
20669            deny: Arc::clone(&deny),
20670        });
20671        let materializer = SessionEnvironmentMaterializer::new(
20672            materialization_root,
20673            resolver,
20674        )
20675        .unwrap();
20676        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20677        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20678            "materialized-claude-r1",
20679        )
20680        .unwrap();
20681        let materialization = NodeSessionMaterializationProfile::new(
20682            vec![NodeSessionEnvironmentMutation::SetSecret {
20683                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20684                reference: NodeSecretReference::new("fixture-token").unwrap(),
20685            }],
20686            vec![NodeSessionPathBinding::new(
20687                "GATE4AGENT_PROVIDER_HOME",
20688                NodeSessionPathClass::ProviderHome,
20689            )
20690            .unwrap()],
20691            vec![NodeSessionFile::secret(
20692                NodeSessionPathClass::Config,
20693                "auth/token",
20694                NodeSecretReference::new("fixture-token").unwrap(),
20695            )
20696            .unwrap()],
20697        )
20698        .unwrap();
20699        let catalog = active_registry().unwrap();
20700        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20701        let control = runtime.native_launch_profile_control();
20702        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20703            profile_id.clone(),
20704            profile_revision.clone(),
20705            agent("claude"),
20706            [NativeLaunchProfile::new(
20707                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20708                agent("claude"),
20709                TransportKind::Pty,
20710                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20711                Arc::new(EmptyEnvironmentResolver),
20712            )
20713            .unwrap()],
20714            Some(materialization),
20715        )
20716        .unwrap();
20717        let (binding, native_profiles, materialization) = node_profile.into_parts();
20718        for native_profile in native_profiles {
20719            runtime.upsert_native_launch_profile(native_profile).unwrap();
20720        }
20721        let shared = NodeShared::new_with_incarnation(
20722            handle,
20723            "fixture-token".to_owned(),
20724            NodeId::new("node-materialization-test").unwrap(),
20725            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20726            vec![workspace],
20727            vec![agent("claude")],
20728            ProviderRuntimeStatuses::default(),
20729            None,
20730            Vec::new(),
20731            Vec::new(),
20732            SpawnProfileRegistry::default(),
20733            Some(control),
20734            None,
20735            Some(materializer),
20736            None,
20737            Vec::new(),
20738            Vec::new(),
20739            Vec::new(),
20740            Vec::new(),
20741            None,
20742            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20743        );
20744        shared
20745            .environment_profiles
20746            .write()
20747            .unwrap_or_else(|poisoned| poisoned.into_inner())
20748            .insert(binding.id.clone(), binding);
20749        shared
20750            .environment_materialization_profiles
20751            .write()
20752            .unwrap_or_else(|poisoned| poisoned.into_inner())
20753            .insert(profile_id.clone(), materialization.unwrap());
20754        (
20755            shared,
20756            runtime,
20757            ResolvedEnvironmentProfileReceipt {
20758                profile_id,
20759                profile_revision,
20760                network_allowlist: None,
20761                browser_profile_id: None,
20762            },
20763            root,
20764            deny,
20765        )
20766    }
20767
20768    #[cfg(feature = "fixture")]
20769    #[derive(Clone, Copy)]
20770    enum CodexHomeProfile {
20771        Missing,
20772        WrongClass,
20773        Exact,
20774    }
20775
20776    #[cfg(feature = "fixture")]
20777    fn codex_bundle_test_shared(
20778        home_profile: CodexHomeProfile,
20779    ) -> (
20780        NodeShared,
20781        NativeRuntime,
20782        ResolvedEnvironmentProfileReceipt,
20783        NodeBundle,
20784        PathBuf,
20785    ) {
20786        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20787        const CLAUDE_MANIFEST: &[u8] =
20788            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20789        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20790        const DIGEST: &str =
20791            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20792
20793        let (shared, mut runtime, _claude_receipt, root, _deny) =
20794            materialization_test_shared(false);
20795        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20796            CodexHomeProfile::Missing => "codex-home-missing",
20797            CodexHomeProfile::WrongClass => "codex-home-wrong",
20798            CodexHomeProfile::Exact => "codex-home-exact",
20799        })
20800        .unwrap();
20801        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20802            "codex-home-r1",
20803        )
20804        .unwrap();
20805        let materialization = match home_profile {
20806            CodexHomeProfile::Missing => None,
20807            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20808                Some(NodeSessionMaterializationProfile::new(
20809                    Vec::new(),
20810                    vec![NodeSessionPathBinding::new(
20811                        "CODEX_HOME",
20812                        if matches!(home_profile, CodexHomeProfile::Exact) {
20813                            NodeSessionPathClass::ProviderHome
20814                        } else {
20815                            NodeSessionPathClass::Config
20816                        },
20817                    )
20818                    .unwrap()],
20819                    Vec::new(),
20820                )
20821                .unwrap())
20822            }
20823        };
20824        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20825            profile_id.clone(),
20826            profile_revision.clone(),
20827            agent("codex"),
20828            [NativeLaunchProfile::new(
20829                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20830                agent("codex"),
20831                TransportKind::Pty,
20832                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20833                Arc::new(EmptyEnvironmentResolver),
20834            )
20835            .unwrap()],
20836            materialization,
20837        )
20838        .unwrap();
20839        let (binding, native_profiles, materialization) = node_profile.into_parts();
20840        for native_profile in native_profiles {
20841            runtime.upsert_native_launch_profile(native_profile).unwrap();
20842        }
20843        shared
20844            .environment_profiles
20845            .write()
20846            .unwrap_or_else(|poisoned| poisoned.into_inner())
20847            .insert(binding.id.clone(), binding);
20848        if let Some(materialization) = materialization {
20849            shared
20850                .environment_materialization_profiles
20851                .write()
20852                .unwrap_or_else(|poisoned| poisoned.into_inner())
20853                .insert(profile_id.clone(), materialization);
20854        }
20855
20856        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20857        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20858        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20859        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20860        std::fs::write(
20861            bundle_source.join(".claude-plugin/plugin.json"),
20862            CLAUDE_MANIFEST,
20863        )
20864        .unwrap();
20865        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20866        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20867        let bundle = NodeBundle::new(
20868            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20869            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20870            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20871            &bundle_source,
20872        )
20873        .unwrap();
20874        *shared
20875            .bundle_catalog
20876            .write()
20877            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20878            BundleCatalog::new([bundle.clone()]).unwrap();
20879        (
20880            shared,
20881            runtime,
20882            ResolvedEnvironmentProfileReceipt {
20883                profile_id,
20884                profile_revision,
20885                network_allowlist: None,
20886                browser_profile_id: None,
20887            },
20888            bundle,
20889            root,
20890        )
20891    }
20892
20893    #[cfg(feature = "fixture")]
20894    #[test]
20895    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20896        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20897            let (shared, runtime, receipt, bundle, root) =
20898                codex_bundle_test_shared(home_profile);
20899            let error = shared
20900                .bundle_layout(
20901                    &agent("codex"),
20902                    SessionMode::Pty,
20903                    Some(&receipt),
20904                    &bundle,
20905                )
20906                .unwrap_err();
20907            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20908            assert!(shared
20909                .materializations
20910                .lock()
20911                .unwrap_or_else(|poisoned| poisoned.into_inner())
20912                .is_empty());
20913            let materialization_root = root.join("session-environments");
20914            assert!(!std::fs::read_dir(&materialization_root)
20915                .unwrap()
20916                .filter_map(Result::ok)
20917                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20918            drop(shared);
20919            drop(runtime);
20920            let _ = std::fs::remove_dir_all(root);
20921        }
20922    }
20923
20924    #[cfg(feature = "fixture")]
20925    #[test]
20926    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20927        let (shared, runtime, receipt, bundle, root) =
20928            codex_bundle_test_shared(CodexHomeProfile::Exact);
20929        let address = SessionAddress {
20930            workspace_id: WorkspaceId::new("primary").unwrap(),
20931            session: SessionKey {
20932                instance_id: AgentInstanceId(310),
20933                generation: SessionGeneration(1),
20934            },
20935        };
20936        let (overlay, guard) = shared
20937            .prepare_session_materialization(
20938                &address,
20939                &agent("codex"),
20940                SessionMode::Pty,
20941                Some(&receipt),
20942                Some(&bundle.receipt()),
20943                None,
20944                None,
20945            )
20946            .unwrap()
20947            .unwrap();
20948        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20949        let ownership = shared
20950            .materializations
20951            .lock()
20952            .unwrap_or_else(|poisoned| poisoned.into_inner())
20953            .get(guard.id().unwrap())
20954            .unwrap()
20955            .clone();
20956        let (profile, _, _) = shared
20957            .materialization_profile(
20958                &agent("codex"),
20959                SessionMode::Pty,
20960                Some(&receipt),
20961                Some(&bundle.receipt()),
20962                None,
20963            )
20964            .unwrap()
20965            .unwrap();
20966        let environment = shared
20967            .session_environment_materializer
20968            .as_ref()
20969            .unwrap()
20970            .resolve_environment(&ownership, &profile)
20971            .unwrap();
20972        assert!(environment.iter().any(|mutation| {
20973            mutation.key == OsString::from("CODEX_HOME")
20974                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20975        }));
20976        assert_eq!(
20977            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20978                .unwrap(),
20979            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20980        );
20981        assert!(std::fs::read_dir(ownership.bundle_root())
20982            .unwrap()
20983            .next()
20984            .is_none());
20985        drop(guard);
20986        drop(shared);
20987        drop(runtime);
20988        let _ = std::fs::remove_dir_all(root);
20989    }
20990
20991    #[cfg(feature = "fixture")]
20992    #[test]
20993    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20994        let (shared, runtime, receipt, bundle, root) =
20995            codex_bundle_test_shared(CodexHomeProfile::Exact);
20996        let bundle_receipt = bundle.receipt();
20997        let address = SessionAddress {
20998            workspace_id: WorkspaceId::new("primary").unwrap(),
20999            session: SessionKey {
21000                instance_id: AgentInstanceId(311),
21001                generation: SessionGeneration(1),
21002            },
21003        };
21004        let (_overlay, guard) = shared
21005            .prepare_session_materialization(
21006                &address,
21007                &agent("codex"),
21008                SessionMode::Pty,
21009                Some(&receipt),
21010                Some(&bundle_receipt),
21011                None,
21012                None,
21013            )
21014            .unwrap()
21015            .unwrap();
21016        let materialization_id = guard.id().unwrap().clone();
21017        let skill_path = shared
21018            .materializations
21019            .lock()
21020            .unwrap_or_else(|poisoned| poisoned.into_inner())
21021            .get(&materialization_id)
21022            .unwrap()
21023            .provider_home()
21024            .join("skills/review-code/SKILL.md");
21025        let record_id = shared
21026            .bind_spawn_session_with_materialization(
21027                &address,
21028                agent("codex"),
21029                SessionMode::Pty,
21030                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21031                SpawnRecordPolicy::ProviderIdentityOnly,
21032                Some(receipt.clone()),
21033                Some(bundle_receipt.clone()),
21034                None,
21035                Some(materialization_id.clone()),
21036            )
21037            .unwrap()
21038            .unwrap();
21039        guard.retain();
21040        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21041
21042        let error = match shared.resolve_record_materialization(
21043                &record_id,
21044                &agent("codex"),
21045                SessionMode::Pty,
21046                Some(&receipt),
21047                Some(&bundle_receipt),
21048                None,
21049            ) {
21050                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21051                Err(error) => error,
21052            };
21053        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21054        assert_eq!(
21055            shared
21056                .materializations
21057                .lock()
21058                .unwrap_or_else(|poisoned| poisoned.into_inner())
21059                .get(&materialization_id)
21060                .unwrap()
21061                .state(),
21062            MaterializationState::RecoveryRequired,
21063        );
21064        drop(shared);
21065        drop(runtime);
21066        let _ = std::fs::remove_dir_all(root);
21067    }
21068
21069    fn bind_record_owned_materialization_fixture(
21070        shared: &NodeShared,
21071        receipt: &ResolvedEnvironmentProfileReceipt,
21072        instance_id: u64,
21073    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21074        let address = SessionAddress {
21075            workspace_id: WorkspaceId::new("primary").unwrap(),
21076            session: SessionKey {
21077                instance_id: AgentInstanceId(instance_id),
21078                generation: SessionGeneration(1),
21079            },
21080        };
21081        let (overlay, materialization_guard) = shared
21082            .prepare_session_materialization(
21083                &address,
21084                &agent("claude"),
21085                SessionMode::Pty,
21086                Some(receipt),
21087                None,
21088                None,
21089                None,
21090            )
21091            .unwrap()
21092            .unwrap();
21093        let materialization_id = materialization_guard.id().unwrap().clone();
21094        let materialization_root = shared
21095            .materializations
21096            .lock()
21097            .unwrap_or_else(|poisoned| poisoned.into_inner())
21098            .get(&materialization_id)
21099            .unwrap()
21100            .root()
21101            .to_path_buf();
21102        let selection = shared
21103            .select_environment_profile(
21104                address.session.instance_id,
21105                &agent("claude"),
21106                SessionMode::Pty,
21107                Some(receipt),
21108            )
21109            .unwrap()
21110            .unwrap();
21111        assert!(shared
21112            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21113            .unwrap()
21114            .is_none());
21115        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21116        let record_id = shared
21117            .bind_spawn_session_with_materialization(
21118                &address,
21119                agent("claude"),
21120                SessionMode::Pty,
21121                policy,
21122                SpawnRecordPolicy::ProviderIdentityOnly,
21123                Some(receipt.clone()),
21124                None,
21125                None,
21126                Some(materialization_id.clone()),
21127            )
21128            .unwrap()
21129            .unwrap();
21130        selection.retain();
21131        materialization_guard.retain();
21132        (address, record_id, materialization_id, materialization_root)
21133    }
21134
21135    #[test]
21136    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21137        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21138        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21139            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21140        }));
21141        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21142        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21143        let mut spec = spawn_spec_fixture();
21144        spec.expected_profile_revision =
21145            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21146        assert!(matches!(
21147            &spec.overrides.environment_profile_id,
21148            crate::protocol::SpawnOverride::Inherit,
21149        ));
21150        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21151            .requires_child_environment_profile_capability());
21152        assert!(request_requires_child_environment_profile(
21153            &shared,
21154            &NodeRequest::SpawnSpec { spec: spec.clone() },
21155        ));
21156
21157        let address = terminal_address(1);
21158        shared.bind_session_with_policy(
21159            &address,
21160            ProviderRuntimePolicy::raw_pty(),
21161            Some(environment_profile.clone()),
21162        );
21163        assert!(request_requires_child_environment_profile(
21164            &shared,
21165            &NodeRequest::Input {
21166                session: address.clone(),
21167                text: "x".to_owned(),
21168            },
21169        ));
21170
21171        let mut record = record("claude", "profile-record");
21172        record.active_session = Some(address.clone());
21173        record.environment_profile = Some(environment_profile.clone());
21174        let record_id = record.record_id.clone();
21175        shared.insert_record(record.clone()).unwrap();
21176        assert!(request_requires_child_environment_profile(
21177            &shared,
21178            &NodeRequest::ResumeSessionRecord {
21179                record_id,
21180                terminal_size: gate4agent_types::TerminalSize {
21181                    rows: 24,
21182                    columns: 80,
21183                },
21184                initial_prompt: None,
21185            },
21186        ));
21187
21188        let snapshot = snapshot_for_wire(
21189            &shared, false, true, true, true, false, false, false, false, true,
21190        );
21191        assert_eq!(snapshot.session_records.len(), 1);
21192        assert!(snapshot.session_records[0].environment_profile.is_none());
21193        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21194            sequence: 1,
21195            event: NodeEvent::SessionRecordUpserted { record },
21196        });
21197        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21198            panic!("record projection changed the event kind");
21199        };
21200        assert!(record.environment_profile.is_none());
21201
21202        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21203        let mut reply = ResponseEnvelope {
21204            request_id: 1,
21205            result: Ok(NodeResponse::SpawnSpecAccepted {
21206                receipt: resolved.receipt_with_environment(
21207                    shared.incarnation_id,
21208                    address,
21209                    Some(environment_profile),
21210                ),
21211            }),
21212        };
21213        project_response_without_child_environment_profile(&mut reply);
21214        assert_eq!(
21215            reply.result.unwrap_err().code,
21216            NodeFailureCode::UnsupportedCapability,
21217        );
21218    }
21219
21220    #[test]
21221    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21222        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21223        let snapshot = shared.snapshot();
21224        let profiles = snapshot
21225            .launch_inventory
21226            .as_ref()
21227            .and_then(|inventory| inventory.spawn_profiles.as_ref())
21228            .unwrap();
21229        assert_eq!(profiles.len(), 1);
21230        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21231        assert!(snapshot.requires_child_environment_profile_capability());
21232
21233        let mut legacy = snapshot.clone();
21234        clear_snapshot_child_environment_profiles(&mut legacy);
21235        assert!(legacy
21236            .launch_inventory
21237            .as_ref()
21238            .unwrap()
21239            .spawn_profiles
21240            .as_ref()
21241            .unwrap()[0]
21242            .environment_profile
21243            .is_none());
21244        assert!(!legacy.requires_child_environment_profile_capability());
21245
21246        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21247        let bindings = shared
21248            .environment_profiles
21249            .read()
21250            .unwrap_or_else(|poisoned| poisoned.into_inner())
21251            .clone();
21252        let mut provider_mismatch = bindings.clone();
21253        provider_mismatch
21254            .values_mut()
21255            .next()
21256            .unwrap()
21257            .provider = agent("codex");
21258        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21259        let mut mode_mismatch = profile;
21260        mode_mismatch.mode = SessionMode::Inline;
21261        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21262
21263        shared
21264            .environment_profiles
21265            .write()
21266            .unwrap_or_else(|poisoned| poisoned.into_inner())
21267            .clear();
21268        assert!(shared
21269            .snapshot()
21270            .launch_inventory
21271            .unwrap()
21272            .spawn_profiles
21273            .unwrap()
21274            .is_empty());
21275    }
21276
21277    #[test]
21278    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21279        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21280            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21281        }));
21282        let shared = terminal_test_shared();
21283        let bundle = ResolvedBundleReceipt {
21284            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21285            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21286            digest: crate::protocol::SpawnBundleDigest::new(format!(
21287                "sha256:{}",
21288                "0".repeat(64),
21289            ))
21290            .unwrap(),
21291        };
21292        let mut bundled = record("claude", "bundle-record");
21293        bundled.bundle = Some(bundle.clone());
21294        let record_id = bundled.record_id.clone();
21295        shared.insert_record(bundled.clone()).unwrap();
21296        assert!(request_requires_session_bundle(
21297            &shared,
21298            &NodeRequest::ForgetSessionRecord {
21299                record_id: record_id.clone(),
21300            },
21301        ));
21302
21303        let snapshot = snapshot_for_wire(
21304            &shared, false, true, true, true, true, false, false, false, true,
21305        );
21306        assert!(snapshot.session_records[0].bundle.is_none());
21307        let launch = snapshot.launch_inventory.as_ref().unwrap();
21308        assert!(launch.spawn_profiles.is_some());
21309        assert!(launch.bundles.is_none());
21310        let event = project_event_without_session_bundle(NodeEventEnvelope {
21311            sequence: 1,
21312            event: NodeEvent::SessionRecordUpserted { record: bundled },
21313        });
21314        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21315            panic!("record projection changed the event kind");
21316        };
21317        assert!(record.bundle.is_none());
21318
21319        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21320        let mut reply = ResponseEnvelope {
21321            request_id: 1,
21322            result: Ok(NodeResponse::SpawnSpecAccepted {
21323                receipt: resolved.receipt_with_materialization(
21324                    shared.incarnation_id,
21325                    terminal_address(17),
21326                    None,
21327                    Some(bundle),
21328                    None,
21329                ),
21330            }),
21331        };
21332        project_response_without_session_bundle(&mut reply);
21333        assert_eq!(
21334            reply.result.unwrap_err().code,
21335            NodeFailureCode::UnsupportedCapability,
21336        );
21337    }
21338
21339    #[test]
21340    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21341        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21342            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21343        }));
21344        let context_id = SpawnContextId::new("context-review").unwrap();
21345        let context = ResolvedContextPackReceipt {
21346            id: context_id.clone(),
21347            digest: crate::protocol::SpawnContextDigest::new(format!(
21348                "sha256:{}",
21349                "c".repeat(64),
21350            ))
21351            .unwrap(),
21352            lineage: ContextPackLineageReceipt {
21353                source_node_id: NodeId::new("node-source").unwrap(),
21354                source_session: terminal_address(1),
21355                source_provider: agent("kimi"),
21356            },
21357            source_message_count: 2,
21358            retained_message_count: 2,
21359            byte_len: 256,
21360            truncated: false,
21361        };
21362        assert!(context.is_valid());
21363        let mut bound_record = record("codex", "context-record");
21364        bound_record.context_id = Some(context_id);
21365        bound_record.context = Some(context.clone());
21366        let session = gate4agent_types::SessionSnapshot {
21367            instance_id: AgentInstanceId(91),
21368            agent_id: agent("kimi"),
21369            transport: TransportKind::Pty,
21370            generation: SessionGeneration(3),
21371            status: SessionStatus::Running,
21372            pending_operation: None,
21373            pending_input: None,
21374            process_id: Some(9001),
21375            terminal_size: None,
21376            terminal_frame: None,
21377            terminal_stale: None,
21378            session_options: None,
21379            capabilities: gate4agent_types::CapabilitySnapshot::default(),
21380            history: gate4agent_types::HistorySnapshot {
21381                pending: None,
21382                candidates: vec![HistoryCandidateSummary {
21383                    id: "candidate-opaque".to_owned(),
21384                    session_id_hint: "session-hint".to_owned(),
21385                    modified_at_unix_ms: Some(1),
21386                }],
21387                loaded_candidate_id: Some("candidate-opaque".to_owned()),
21388                loaded: Some(HistorySessionRecord {
21389                    session_id: "private-vendor-session".to_owned(),
21390                    title: Some("private title".to_owned()),
21391                    cwd: Some(r"C:\private\history-root".to_owned()),
21392                    model: Some("private-model".to_owned()),
21393                    message_count: 2,
21394                    completed_turn_count: None,
21395                    total_tokens: 19,
21396                    messages: vec![
21397                        gate4agent_types::HistoryMessageRecord {
21398                            role: gate4agent_types::HistoryMessageRole::User,
21399                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21400                        },
21401                        gate4agent_types::HistoryMessageRecord {
21402                            role: gate4agent_types::HistoryMessageRole::Assistant,
21403                            text: "private answer".to_owned(),
21404                        },
21405                    ],
21406                }),
21407                last_error: None,
21408            },
21409            resume: gate4agent_types::ResumeSnapshot::default(),
21410            foreground: gate4agent_types::ForegroundSnapshot::default(),
21411            provider: gate4agent_types::ProviderSnapshot::default(),
21412            screen_state: gate4agent_types::PtyScreenState::default(),
21413        };
21414        let original = NodeSnapshot {
21415            node_id: NodeId::new("node-terminal-test").unwrap(),
21416            enabled_providers: vec![agent("kimi"), agent("codex")],
21417            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21418            workspaces: vec![WorkspaceSnapshot {
21419                workspace_id: WorkspaceId::new("primary").unwrap(),
21420                canonical_root: opaque_windows_path(
21421                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21422                ),
21423                sessions: vec![session],
21424                worktree_service_mode: None,
21425                managed_worktree_profiles: None,
21426            }],
21427            session_records: vec![bound_record],
21428            managed_worktrees: Vec::new(),
21429            launch_inventory: None,
21430            agent_progress: Vec::new(),
21431        };
21432
21433        let mut negotiated = original.clone();
21434        project_snapshot_history_for_wire(&mut negotiated, true);
21435        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21436        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21437        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21438        assert!(!serde_json::to_string(&negotiated)
21439            .unwrap()
21440            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21441
21442        let mut legacy = original;
21443        project_snapshot_history_for_wire(&mut legacy, false);
21444        clear_snapshot_context_packs(&mut legacy);
21445        assert_eq!(
21446            legacy.workspaces[0].sessions[0].history,
21447            gate4agent_types::HistorySnapshot::default(),
21448        );
21449        assert!(legacy.session_records[0].context_id.is_none());
21450        assert!(legacy.session_records[0].context.is_none());
21451        let encoded = serde_json::to_string(&legacy).unwrap();
21452        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21453        assert!(!encoded.contains("context-review"));
21454
21455        let mut reply = ResponseEnvelope {
21456            request_id: 7,
21457            result: Ok(NodeResponse::ContextPackExported { context }),
21458        };
21459        project_response_without_context_pack(&mut reply);
21460        assert_eq!(
21461            reply.result.unwrap_err().code,
21462            NodeFailureCode::UnsupportedCapability,
21463        );
21464    }
21465
21466    #[test]
21467    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21468        let shared = terminal_test_shared();
21469        let history = HistorySessionRecord {
21470            session_id: "fixture-history".to_owned(),
21471            title: None,
21472            cwd: None,
21473            model: None,
21474            message_count: 1,
21475            completed_turn_count: None,
21476            total_tokens: 0,
21477            messages: vec![gate4agent_types::HistoryMessageRecord {
21478                role: gate4agent_types::HistoryMessageRole::User,
21479                text: "bounded handoff".to_owned(),
21480            }],
21481        };
21482        let pack = NodeContextPack::export(
21483            ContextPackLineageReceipt {
21484                source_node_id: shared.node_id.clone(),
21485                source_session: terminal_address(1),
21486                source_provider: agent("claude"),
21487            },
21488            &history,
21489        )
21490        .unwrap();
21491        let context = shared
21492            .context_catalog
21493            .write()
21494            .unwrap_or_else(|poisoned| poisoned.into_inner())
21495            .insert(pack)
21496            .unwrap();
21497        let mut retained = record("codex", "context-retained-record");
21498        retained.context_id = Some(context.id.clone());
21499        retained.context = Some(context.clone());
21500        let record_id = retained.record_id.clone();
21501        shared.insert_record(retained).unwrap();
21502        assert_eq!(
21503            shared.forget_context_pack(&context.id).unwrap_err().code,
21504            NodeFailureCode::ContextPackBusy,
21505        );
21506        assert!(shared
21507            .context_catalog
21508            .read()
21509            .unwrap_or_else(|poisoned| poisoned.into_inner())
21510            .get(&context.id)
21511            .is_some());
21512        shared
21513            .session_records
21514            .lock()
21515            .unwrap_or_else(|poisoned| poisoned.into_inner())
21516            .records
21517            .remove(&record_id);
21518
21519        let mut spec = spawn_spec_fixture();
21520        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21521            value: context.id.clone(),
21522        };
21523        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21524        let receipt = resolved.receipt_with_materialization(
21525            shared.incarnation_id,
21526            terminal_address(2),
21527            None,
21528            None,
21529            Some(context.clone()),
21530        );
21531        assert!(receipt.context_binding_is_valid());
21532        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21533
21534        shared.forget_context_pack(&context.id).unwrap();
21535        assert!(shared
21536            .context_catalog
21537            .read()
21538            .unwrap_or_else(|poisoned| poisoned.into_inner())
21539            .get(&context.id)
21540            .is_none());
21541        assert!(shared
21542            .replay_spawn_spec(&spec, Instant::now())
21543            .unwrap()
21544            .is_none());
21545    }
21546
21547    #[test]
21548    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21549        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21550        let address = terminal_address(1);
21551        let selection = shared
21552            .select_environment_profile(
21553                address.session.instance_id,
21554                &agent("claude"),
21555                SessionMode::Pty,
21556                Some(&environment_profile),
21557            )
21558            .unwrap()
21559            .unwrap();
21560        shared.bind_session_with_policy(
21561            &address,
21562            ProviderRuntimePolicy::raw_pty(),
21563            Some(environment_profile.clone()),
21564        );
21565        selection.retain();
21566        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21567        assert!(matches!(
21568            runtime.remove_native_launch_profile(&native_id),
21569            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21570        ));
21571        assert!(shared.remove_binding(&address).is_some());
21572        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21573
21574        let mismatched = ResolvedEnvironmentProfileReceipt {
21575            profile_id: environment_profile.profile_id,
21576            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21577                "local-claude-r2",
21578            )
21579            .unwrap(),
21580            network_allowlist: None,
21581            browser_profile_id: None,
21582        };
21583        let error = match shared.select_environment_profile(
21584                AgentInstanceId(42),
21585                &agent("claude"),
21586                SessionMode::Pty,
21587                Some(&mismatched),
21588            ) {
21589            Ok(_) => panic!("mismatched environment profile revision was selected"),
21590            Err(error) => error,
21591        };
21592        assert_eq!(
21593            error.code,
21594            NodeFailureCode::EnvironmentProfileBindingMismatch,
21595        );
21596        assert!(!shared
21597            .session_bindings
21598            .lock()
21599            .unwrap_or_else(|poisoned| poisoned.into_inner())
21600            .contains_key(&AgentInstanceId(42)));
21601    }
21602
21603    #[test]
21604    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21605        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21606        let address = terminal_address(1);
21607        let selection = shared
21608            .select_environment_profile(
21609                address.session.instance_id,
21610                &agent("claude"),
21611                SessionMode::Pty,
21612                Some(&environment_profile),
21613            )
21614            .unwrap()
21615            .unwrap();
21616        let overlay = NativeInstanceLaunchOverlay::new(
21617            agent("claude"),
21618            TransportKind::Pty,
21619            vec![EnvMutation {
21620                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21621                value: Some(OsString::from("fixture-secret")),
21622            }],
21623            vec![
21624                OsString::from("--plugin-dir"),
21625                std::env::current_dir().unwrap().into_os_string(),
21626            ],
21627        )
21628        .unwrap();
21629        let instance_overlay = shared
21630            .install_prepared_launch_overlay(
21631                address.session.instance_id,
21632                PreparedNativeLaunchOverlay::Instance(overlay),
21633            )
21634            .unwrap()
21635            .unwrap();
21636        let bundle = ResolvedBundleReceipt {
21637            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21638            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21639            digest: crate::protocol::SpawnBundleDigest::new(format!(
21640                "sha256:{}",
21641                "0".repeat(64),
21642            ))
21643            .unwrap(),
21644        };
21645        shared.bind_session_with_materialization(
21646            &address,
21647            ProviderRuntimePolicy::raw_pty(),
21648            Some(environment_profile),
21649            Some(bundle),
21650            None,
21651            None,
21652        );
21653        selection.retain();
21654        instance_overlay.retain();
21655
21656        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21657        assert!(matches!(
21658            runtime.remove_native_launch_profile(&native_id),
21659            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21660        ));
21661        assert!(shared.remove_binding(&address).is_some());
21662        assert!(!shared
21663            .native_launch_profile_control
21664            .as_ref()
21665            .unwrap()
21666            .clear_native_instance_launch_overlay(address.session.instance_id));
21667        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21668    }
21669
21670    #[test]
21671    fn session_environment_materialization_failure_is_pre_child_and_durable() {
21672        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21673        let address = SessionAddress {
21674            workspace_id: WorkspaceId::new("primary").unwrap(),
21675            session: SessionKey {
21676                instance_id: AgentInstanceId(201),
21677                generation: SessionGeneration(1),
21678            },
21679        };
21680        let error = match shared.prepare_session_materialization(
21681            &address,
21682            &agent("claude"),
21683            SessionMode::Pty,
21684            Some(&receipt),
21685            None,
21686            None,
21687            None,
21688        ) {
21689            Ok(_) => panic!("denied local reference unexpectedly materialized"),
21690            Err(error) => error,
21691        };
21692        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21693        assert!(shared.handle.snapshot().sessions.is_empty());
21694        assert!(shared
21695            .session_bindings
21696            .lock()
21697            .unwrap_or_else(|poisoned| poisoned.into_inner())
21698            .is_empty());
21699        assert!(shared
21700            .materializations
21701            .lock()
21702            .unwrap_or_else(|poisoned| poisoned.into_inner())
21703            .is_empty());
21704        drop(shared);
21705        drop(runtime);
21706        std::fs::remove_dir_all(root).unwrap();
21707    }
21708
21709    #[test]
21710    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21711        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21712        let address = SessionAddress {
21713            workspace_id: WorkspaceId::new("primary").unwrap(),
21714            session: SessionKey {
21715                instance_id: AgentInstanceId(202),
21716                generation: SessionGeneration(1),
21717            },
21718        };
21719        let (overlay, materialization_guard) = shared
21720            .prepare_session_materialization(
21721                &address,
21722                &agent("claude"),
21723                SessionMode::Pty,
21724                Some(&receipt),
21725                None,
21726                None,
21727                None,
21728            )
21729            .unwrap()
21730            .unwrap();
21731        let materialization_id = materialization_guard.id().unwrap().clone();
21732        let materialization_root = shared
21733            .materializations
21734            .lock()
21735            .unwrap_or_else(|poisoned| poisoned.into_inner())
21736            .get(&materialization_id)
21737            .unwrap()
21738            .root()
21739            .to_path_buf();
21740        let selection = shared
21741            .select_environment_profile(
21742                address.session.instance_id,
21743                &agent("claude"),
21744                SessionMode::Pty,
21745                Some(&receipt),
21746            )
21747            .unwrap()
21748            .unwrap();
21749        assert!(shared
21750            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21751            .unwrap()
21752            .is_none());
21753        shared.bind_session_with_materialization(
21754            &address,
21755            ProviderRuntimePolicy::raw_pty(),
21756            Some(receipt),
21757            None,
21758            None,
21759            Some(materialization_id),
21760        );
21761        selection.retain();
21762        materialization_guard.retain();
21763
21764        assert!(materialization_root.is_dir());
21765        assert!(shared
21766            .session_bindings
21767            .lock()
21768            .unwrap_or_else(|poisoned| poisoned.into_inner())
21769            .contains_key(&address.session.instance_id));
21770
21771        let removed = shared.remove_binding(&address).unwrap();
21772        shared
21773            .cleanup_session_owned_materialization(&address, &removed)
21774            .unwrap();
21775        assert!(!materialization_root.exists());
21776        assert!(shared
21777            .materializations
21778            .lock()
21779            .unwrap_or_else(|poisoned| poisoned.into_inner())
21780            .is_empty());
21781        drop(shared);
21782        drop(runtime);
21783        std::fs::remove_dir_all(root).unwrap();
21784    }
21785
21786    #[tokio::test]
21787    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21788        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21789        let address = SessionAddress {
21790            workspace_id: WorkspaceId::new("primary").unwrap(),
21791            session: SessionKey {
21792                instance_id: AgentInstanceId(203),
21793                generation: SessionGeneration(1),
21794            },
21795        };
21796        let (overlay, materialization_guard) = shared
21797            .prepare_session_materialization(
21798                &address,
21799                &agent("claude"),
21800                SessionMode::Pty,
21801                Some(&receipt),
21802                None,
21803                None,
21804                None,
21805            )
21806            .unwrap()
21807            .unwrap();
21808        let materialization_id = materialization_guard.id().unwrap().clone();
21809        let materialization_root = shared
21810            .materializations
21811            .lock()
21812            .unwrap_or_else(|poisoned| poisoned.into_inner())
21813            .get(&materialization_id)
21814            .unwrap()
21815            .root()
21816            .to_path_buf();
21817        let selection = shared
21818            .select_environment_profile(
21819                address.session.instance_id,
21820                &agent("claude"),
21821                SessionMode::Pty,
21822                Some(&receipt),
21823            )
21824            .unwrap()
21825            .unwrap();
21826        assert!(shared
21827            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21828            .unwrap()
21829            .is_none());
21830        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21831        let record_id = shared
21832            .bind_spawn_session_with_materialization(
21833                &address,
21834                agent("claude"),
21835                SessionMode::Pty,
21836                policy,
21837                SpawnRecordPolicy::ProviderIdentityOnly,
21838                Some(receipt.clone()),
21839                None,
21840                None,
21841                Some(materialization_id.clone()),
21842            )
21843            .unwrap()
21844            .unwrap();
21845        selection.retain();
21846        materialization_guard.retain();
21847        assert_eq!(
21848            shared
21849                .materializations
21850                .lock()
21851                .unwrap_or_else(|poisoned| poisoned.into_inner())
21852                .get(&materialization_id)
21853                .unwrap()
21854                .owner(),
21855            &MaterializationOwner::Record {
21856                record_id: record_id.clone(),
21857            },
21858        );
21859
21860        let removed = shared.remove_binding(&address).unwrap();
21861        shared
21862            .cleanup_session_owned_materialization(&address, &removed)
21863            .unwrap();
21864        assert!(materialization_root.is_dir());
21865        assert!(shared
21866            .resolve_record_materialization(
21867                &record_id,
21868                &agent("claude"),
21869                SessionMode::Pty,
21870                Some(&receipt),
21871                None,
21872                None,
21873            )
21874            .unwrap()
21875            .is_some());
21876        {
21877            let mut records = shared
21878                .session_records
21879                .lock()
21880                .unwrap_or_else(|poisoned| poisoned.into_inner());
21881            let record = records.records.get_mut(&record_id).unwrap();
21882            record.active_session = None;
21883            record.state = ManagedSessionState::Unavailable;
21884        }
21885        shared.forget_session_record(&record_id).await.unwrap();
21886        assert!(!materialization_root.exists());
21887        assert!(shared
21888            .materializations
21889            .lock()
21890            .unwrap_or_else(|poisoned| poisoned.into_inner())
21891            .is_empty());
21892        drop(shared);
21893        drop(runtime);
21894        std::fs::remove_dir_all(root).unwrap();
21895    }
21896
21897    #[test]
21898    fn record_materialization_revalidation_failure_persists_recovery_required() {
21899        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21900        let state_path = root.join("node-state-v6.json");
21901        shared.state_path = Some(state_path.clone());
21902        let (_address, _record_id, materialization_id, materialization_root) =
21903            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21904        std::fs::write(
21905            materialization_root.join(".gate4agent-materialization-owner"),
21906            b"tampered",
21907        )
21908        .unwrap();
21909
21910        let error = match shared.resolve_record_materialization(
21911            &_record_id,
21912            &agent("claude"),
21913            SessionMode::Pty,
21914            Some(&receipt),
21915            None,
21916            None,
21917        ) {
21918            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21919            Err(error) => error,
21920        };
21921        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21922        assert_eq!(
21923            shared
21924                .materializations
21925                .lock()
21926                .unwrap_or_else(|poisoned| poisoned.into_inner())
21927                .get(&materialization_id)
21928                .unwrap()
21929                .state(),
21930            MaterializationState::RecoveryRequired,
21931        );
21932        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21933        assert_eq!(loaded.materializations.len(), 1);
21934        assert_eq!(
21935            loaded.materializations[0].state(),
21936            MaterializationState::RecoveryRequired,
21937        );
21938        drop(shared);
21939        drop(runtime);
21940        std::fs::remove_dir_all(root).unwrap();
21941    }
21942
21943    #[test]
21944    fn transient_record_materialization_resolution_failure_remains_ready() {
21945        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21946        let (_address, record_id, materialization_id, _materialization_root) =
21947            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21948        deny.store(true, Ordering::Release);
21949
21950        let error = match shared.resolve_record_materialization(
21951            &record_id,
21952            &agent("claude"),
21953            SessionMode::Pty,
21954            Some(&receipt),
21955            None,
21956            None,
21957        ) {
21958            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21959            Err(error) => error,
21960        };
21961        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21962        assert_eq!(
21963            shared
21964                .materializations
21965                .lock()
21966                .unwrap_or_else(|poisoned| poisoned.into_inner())
21967                .get(&materialization_id)
21968                .unwrap()
21969                .state(),
21970            MaterializationState::Ready,
21971        );
21972        drop(shared);
21973        drop(runtime);
21974        std::fs::remove_dir_all(root).unwrap();
21975    }
21976
21977    #[test]
21978    fn startup_missing_record_materialization_is_not_resumable() {
21979        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21980        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21981        shared
21982            .insert_record(ManagedSessionRecord {
21983                record_id: record_id.clone(),
21984                display_name: "missing materialization".to_owned(),
21985                provider: agent("claude"),
21986                mode: SessionMode::Pty,
21987                state: ManagedSessionState::Dormant,
21988                workspace_id: WorkspaceId::new("primary").unwrap(),
21989                canonical_root: opaque_windows_path(
21990                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21991                ),
21992                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21993                    key: gate4agent_types::ProviderSessionKey::SessionId,
21994                    id: "provider-session-missing-materialization".to_owned(),
21995                    transcript_path: None,
21996                }),
21997                active_session: None,
21998                environment_profile: Some(receipt),
21999                bundle: None,
22000                context_id: None,
22001                context: None,
22002                exported_context: None,
22003                task_binding: None,
22004                created_at_unix_ms: 1,
22005                updated_at_unix_ms: 1,
22006                last_error: None,
22007            })
22008            .unwrap();
22009
22010        shared.reconcile_materializations();
22011        let record = shared.record(&record_id).unwrap();
22012        assert_eq!(record.state, ManagedSessionState::Unavailable);
22013        assert!(record.provider_session.is_none());
22014        assert_eq!(
22015            record.last_error.as_deref(),
22016            Some("environment-profile-unavailable"),
22017        );
22018        drop(shared);
22019        drop(runtime);
22020        std::fs::remove_dir_all(root).unwrap();
22021    }
22022
22023    #[tokio::test]
22024    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22025        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22026        let lease = install_managed_lease(&shared);
22027        let address = SessionAddress {
22028            workspace_id: WorkspaceId::new("primary").unwrap(),
22029            session: SessionKey {
22030                instance_id: AgentInstanceId(204),
22031                generation: SessionGeneration(1),
22032            },
22033        };
22034        let (overlay, materialization_guard) = shared
22035            .prepare_session_materialization(
22036                &address,
22037                &agent("claude"),
22038                SessionMode::Pty,
22039                Some(&receipt),
22040                None,
22041                None,
22042                Some(lease.lease_id.clone()),
22043            )
22044            .unwrap()
22045            .unwrap();
22046        let materialization_id = materialization_guard.id().unwrap().clone();
22047        let materialization_root = shared
22048            .materializations
22049            .lock()
22050            .unwrap_or_else(|poisoned| poisoned.into_inner())
22051            .get(&materialization_id)
22052            .unwrap()
22053            .root()
22054            .to_path_buf();
22055        let selection = shared
22056            .select_environment_profile(
22057                address.session.instance_id,
22058                &agent("claude"),
22059                SessionMode::Pty,
22060                Some(&receipt),
22061            )
22062            .unwrap()
22063            .unwrap();
22064        assert!(shared
22065            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22066            .unwrap()
22067            .is_none());
22068        shared.bind_session_with_materialization(
22069            &address,
22070            ProviderRuntimePolicy::raw_pty(),
22071            Some(receipt),
22072            None,
22073            None,
22074            Some(materialization_id),
22075        );
22076        selection.retain();
22077        materialization_guard.retain();
22078
22079        let blocked = shared
22080            .cleanup_managed_worktree(&lease.lease_id, false)
22081            .await
22082            .unwrap_err();
22083        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22084        assert!(materialization_root.is_dir());
22085        let removed = shared.remove_binding(&address).unwrap();
22086        shared
22087            .cleanup_session_owned_materialization(&address, &removed)
22088            .unwrap();
22089        assert!(!materialization_root.exists());
22090        drop(shared);
22091        drop(runtime);
22092        std::fs::remove_dir_all(root).unwrap();
22093    }
22094
22095    #[tokio::test]
22096    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22097        let git_root = temporary_workspace_root("raw-managed-inventory");
22098        let source = git_root.join("source");
22099        let allocation = git_root.join("allocation");
22100        std::fs::create_dir_all(&source).unwrap();
22101        std::fs::create_dir_all(&allocation).unwrap();
22102        run_test_git(&source, &["init"]);
22103        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22104        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22105        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22106        run_test_git(&source, &["add", "seed.txt"]);
22107        run_test_git(&source, &["commit", "-m", "seed"]);
22108        let profile = ManagedWorktreeProfile::new(
22109            WorktreeProfileId::new("default").unwrap(),
22110            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22111            &allocation,
22112            "gate4agent",
22113            "HEAD",
22114            ManagedWorktreeRetention::RemoveWhenReleased,
22115        )
22116        .unwrap();
22117        let workspace = WorkspaceConfig::new(
22118            WorkspaceId::new("primary").unwrap(),
22119            &source,
22120        )
22121        .unwrap()
22122        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22123        .with_managed_worktree_profile(profile.clone())
22124        .unwrap();
22125        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22126            materialization_test_shared_with_workspace(false, workspace);
22127        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22128        let source_text = source.to_string_lossy().into_owned();
22129        let target_text = target.to_string_lossy().into_owned();
22130        let created = create_git_worktree(
22131            &source_text,
22132            &target_text,
22133            "gate4agent/mw-raw-inventory",
22134            Some("HEAD"),
22135        )
22136        .await
22137        .unwrap();
22138        let lease = ManagedWorktreeLeaseRecord {
22139            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22140            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22141            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22142            profile_id: profile.profile_id().clone(),
22143            profile_revision: profile.revision().clone(),
22144            target_root: created.path.clone(),
22145            branch: "gate4agent/mw-raw-inventory".to_owned(),
22146            base_commit: created.head.clone(),
22147            expected_head: Some(created.head.clone()),
22148            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22149            state: ManagedWorktreeLeaseState::Ready,
22150            session_holders: Vec::new(),
22151            record_holders: Vec::new(),
22152            cleanup_failure: None,
22153            created_at_unix_ms: 1,
22154            updated_at_unix_ms: 1,
22155        };
22156        shared
22157            .workspaces
22158            .write()
22159            .unwrap_or_else(|poisoned| poisoned.into_inner())
22160            .insert(lease.workspace_id.clone(), created.path.clone());
22161        *shared
22162            .managed_worktrees
22163            .lock()
22164            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22165            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22166        let address = SessionAddress {
22167            workspace_id: lease.workspace_id.clone(),
22168            session: SessionKey {
22169                instance_id: AgentInstanceId(205),
22170                generation: SessionGeneration(1),
22171            },
22172        };
22173        let (overlay, materialization_guard) = shared
22174            .prepare_session_materialization(
22175                &address,
22176                &agent("claude"),
22177                SessionMode::Pty,
22178                Some(&receipt),
22179                None,
22180                None,
22181                Some(lease.lease_id.clone()),
22182            )
22183            .unwrap()
22184            .unwrap();
22185        let materialization_id = materialization_guard.id().unwrap().clone();
22186        let materialization_root = shared
22187            .materializations
22188            .lock()
22189            .unwrap_or_else(|poisoned| poisoned.into_inner())
22190            .get(&materialization_id)
22191            .unwrap()
22192            .root()
22193            .to_path_buf();
22194        let selection = shared
22195            .select_environment_profile(
22196                address.session.instance_id,
22197                &agent("claude"),
22198                SessionMode::Pty,
22199                Some(&receipt),
22200            )
22201            .unwrap()
22202            .unwrap();
22203        assert!(shared
22204            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22205            .unwrap()
22206            .is_none());
22207        let record_id = shared
22208            .bind_spawn_session_with_materialization(
22209                &address,
22210                agent("claude"),
22211                SessionMode::Pty,
22212                ProviderRuntimePolicy::raw_pty(),
22213                SpawnRecordPolicy::Always,
22214                Some(receipt.clone()),
22215                None,
22216                None,
22217                Some(materialization_id.clone()),
22218            )
22219            .unwrap()
22220            .unwrap();
22221        selection.retain();
22222        materialization_guard.retain();
22223        let record = shared.record(&record_id).unwrap();
22224        assert_eq!(record.state, ManagedSessionState::Live);
22225        assert!(record.provider_session.is_none());
22226        assert!(record.environment_profile.is_none());
22227        assert!(record.bundle.is_none());
22228        assert!(record.context_id.is_none());
22229        assert!(record.context.is_none());
22230        assert_eq!(record.active_session.as_ref(), Some(&address));
22231        assert_eq!(
22232            shared
22233                .materializations
22234                .lock()
22235                .unwrap_or_else(|poisoned| poisoned.into_inner())
22236                .get(&materialization_id)
22237                .unwrap()
22238                .owner(),
22239            &MaterializationOwner::Session {
22240                incarnation_id: shared.incarnation_id,
22241                instance_id: address.session.instance_id,
22242                generation: address.session.generation,
22243            },
22244        );
22245        {
22246            let mut bindings = shared
22247                .session_bindings
22248                .lock()
22249                .unwrap_or_else(|poisoned| poisoned.into_inner());
22250            bindings
22251                .get_mut(&address.session.instance_id)
22252                .unwrap()
22253                .managed_worktree_lease_id = Some(lease.lease_id.clone());
22254        }
22255        let lease_snapshot = shared
22256            .managed_worktrees
22257            .lock()
22258            .unwrap_or_else(|poisoned| poisoned.into_inner())
22259            .bind_session(
22260                &lease.lease_id,
22261                ManagedWorktreeSessionHolder {
22262                    incarnation_id: shared.incarnation_id,
22263                    instance_id: address.session.instance_id,
22264                    generation: address.session.generation,
22265                },
22266                None,
22267                unix_time_ms(),
22268            )
22269            .unwrap();
22270        assert_eq!(lease_snapshot.active_session_count, 1);
22271        assert_eq!(lease_snapshot.managed_record_count, 0);
22272        shared.persist_state().unwrap();
22273        assert!(shared
22274            .managed_worktrees
22275            .lock()
22276            .unwrap_or_else(|poisoned| poisoned.into_inner())
22277            .get(&lease.lease_id)
22278            .unwrap()
22279            .record_holders
22280            .is_empty());
22281        shared
22282            .handle
22283            .dispatch(shared.prepare_command(ControlCommand::Register {
22284                instance_id: address.session.instance_id,
22285                agent_id: agent("claude"),
22286                transport: TransportKind::Pty,
22287            }))
22288            .unwrap();
22289        runtime.tick().await;
22290        shared.reconcile_managed_record(
22291            &record_id,
22292            &address,
22293            &ControlEvent {
22294                sequence: 1,
22295                command_id: None,
22296                instance_id: address.session.instance_id,
22297                generation: address.session.generation,
22298                event: ControlEventKind::Removed,
22299            },
22300            false,
22301            false,
22302        );
22303        let remove = shared.remove_session(&address);
22304        let drive_runtime = async {
22305            runtime.tick().await;
22306        };
22307        let (removed, ()) = tokio::join!(remove, drive_runtime);
22308        removed.unwrap();
22309
22310        assert!(!materialization_root.exists());
22311        assert!(!Path::new(&created.path).exists());
22312        assert_eq!(
22313            shared
22314                .managed_worktrees
22315                .lock()
22316                .unwrap_or_else(|poisoned| poisoned.into_inner())
22317                .get(&lease.lease_id)
22318                .unwrap()
22319                .state,
22320            ManagedWorktreeLeaseState::Removed,
22321        );
22322        let renamed = shared
22323            .rename_session_record(&record_id, "raw managed inventory".to_owned())
22324            .unwrap();
22325        assert_eq!(renamed.record_id, record_id);
22326        assert_eq!(renamed.display_name, "raw managed inventory");
22327        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22328        assert!(renamed.active_session.is_none());
22329
22330        drop(shared);
22331        drop(runtime);
22332        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22333        std::fs::remove_dir_all(git_root).unwrap();
22334    }
22335
22336    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22337        let root = std::env::current_dir().unwrap()
22338            .join("managed-test-target")
22339            .to_string_lossy()
22340            .into_owned();
22341        let lease = ManagedWorktreeLeaseRecord {
22342            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22343            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22344            workspace_id: WorkspaceId::new("managed-test").unwrap(),
22345            profile_id: WorktreeProfileId::new("default").unwrap(),
22346            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22347            target_root: root.clone(),
22348            branch: "gate4agent/mw-test".to_owned(),
22349            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22350            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22351            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22352            state: ManagedWorktreeLeaseState::Ready,
22353            session_holders: Vec::new(),
22354            record_holders: Vec::new(),
22355            cleanup_failure: None,
22356            created_at_unix_ms: 1,
22357            updated_at_unix_ms: 1,
22358        };
22359        shared.workspaces.write()
22360            .unwrap_or_else(|poisoned| poisoned.into_inner())
22361            .insert(lease.workspace_id.clone(), root);
22362        *shared.managed_worktrees.lock()
22363            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22364            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22365        lease
22366    }
22367
22368    #[cfg(feature = "fixture")]
22369    #[test]
22370    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22371        let mut shared = terminal_test_shared();
22372        shared.fixture_semantic_hook_policy = true;
22373        let lease = install_managed_lease(&shared);
22374        let address = SessionAddress {
22375            workspace_id: lease.workspace_id.clone(),
22376            session: SessionKey {
22377                instance_id: AgentInstanceId(206),
22378                generation: SessionGeneration(1),
22379            },
22380        };
22381        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22382        let effective_runtime_policy =
22383            shared.effective_spawn_runtime_policy(pre_admission_policy);
22384        assert_ne!(effective_runtime_policy, pre_admission_policy);
22385        assert!(effective_runtime_policy.provider_session_identity);
22386
22387        let record_id = shared
22388            .bind_spawn_session(
22389                &address,
22390                agent("claude"),
22391                SessionMode::Pty,
22392                effective_runtime_policy,
22393                SpawnRecordPolicy::Always,
22394                None,
22395            )
22396            .unwrap()
22397            .unwrap();
22398        assert_eq!(
22399            shared.record(&record_id).unwrap().state,
22400            ManagedSessionState::IdentityPending,
22401        );
22402        let (record_holder, raw_inventory_record_id) = {
22403            let mut bindings = shared
22404                .session_bindings
22405                .lock()
22406                .unwrap_or_else(|poisoned| poisoned.into_inner());
22407            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22408            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22409            managed_spawn_record_ownership(binding, effective_runtime_policy)
22410        };
22411        assert_eq!(record_holder.as_ref(), Some(&record_id));
22412        assert!(raw_inventory_record_id.is_none());
22413        let snapshot = shared
22414            .managed_worktrees
22415            .lock()
22416            .unwrap_or_else(|poisoned| poisoned.into_inner())
22417            .bind_session(
22418                &lease.lease_id,
22419                ManagedWorktreeSessionHolder {
22420                    incarnation_id: shared.incarnation_id,
22421                    instance_id: address.session.instance_id,
22422                    generation: address.session.generation,
22423                },
22424                record_holder,
22425                unix_time_ms(),
22426            )
22427            .unwrap();
22428        assert_eq!(snapshot.managed_record_count, 1);
22429        assert_eq!(
22430            shared
22431                .managed_worktrees
22432                .lock()
22433                .unwrap_or_else(|poisoned| poisoned.into_inner())
22434                .get(&lease.lease_id)
22435                .unwrap()
22436                .record_holders,
22437            vec![record_id.clone()],
22438        );
22439
22440        let identity = ProviderSessionIdentity {
22441            key: ProviderSessionKey::SessionId,
22442            id: "monitoring-fixture-managed-session".to_owned(),
22443            transcript_path: None,
22444        };
22445        shared.publish_control(ControlEvent {
22446            sequence: 1,
22447            command_id: None,
22448            instance_id: address.session.instance_id,
22449            generation: address.session.generation,
22450            event: ControlEventKind::ProviderEvent {
22451                sequence: 1,
22452                source: gate4agent_types::ProviderSource {
22453                    family: AdapterFamily::Hook,
22454                    binding: AdapterBinding::new(
22455                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
22456                        "fixture/v1",
22457                        gate4agent_types::AdapterVerification::SyntheticFixture,
22458                    )
22459                    .unwrap(),
22460                },
22461                source_sequence: 1,
22462                event: ProviderEvent::SessionIdentityObserved {
22463                    identity: identity.clone(),
22464                },
22465            },
22466        });
22467
22468        let live = shared.record(&record_id).unwrap();
22469        assert_eq!(live.state, ManagedSessionState::Live);
22470        assert_eq!(live.provider_session.as_ref(), Some(&identity));
22471        assert_eq!(live.active_session.as_ref(), Some(&address));
22472        assert_eq!(
22473            shared
22474                .managed_worktrees
22475                .lock()
22476                .unwrap_or_else(|poisoned| poisoned.into_inner())
22477                .get(&lease.lease_id)
22478                .unwrap()
22479                .record_holders,
22480            vec![record_id],
22481        );
22482    }
22483
22484    fn managed_v2_test_shared(
22485        allocation_root: &Path,
22486        state_path: Option<PathBuf>,
22487        profile_revision: &str,
22488    ) -> NodeShared {
22489        let profile = ManagedWorktreeProfile::new(
22490            WorktreeProfileId::new("default").unwrap(),
22491            WorktreeProfileRevision::new(profile_revision).unwrap(),
22492            allocation_root,
22493            "gate4agent",
22494            "HEAD",
22495            ManagedWorktreeRetention::RemoveWhenReleased,
22496        )
22497        .unwrap();
22498        let workspace = WorkspaceConfig::new(
22499            WorkspaceId::new("primary").unwrap(),
22500            std::env::current_dir().unwrap(),
22501        )
22502        .unwrap()
22503        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22504        .with_managed_worktree_profile(profile)
22505        .unwrap();
22506        let catalog = active_registry().unwrap();
22507        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22508        let mut shared = NodeShared::new(
22509            handle,
22510            "fixture-token".to_owned(),
22511            NodeId::new("node-terminal-test").unwrap(),
22512            vec![workspace],
22513            vec![agent("claude")],
22514        );
22515        shared.state_path = state_path;
22516        shared
22517    }
22518
22519    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22520        let mut spawn_spec = spawn_spec_fixture();
22521        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22522        ManagedWorktreeSpawnRequestV2 {
22523            spawn_spec,
22524            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22525            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22526        }
22527    }
22528
22529    #[test]
22530    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22531        let shared = terminal_test_shared();
22532        let mut lease = install_managed_lease(&shared);
22533        let session = SessionAddress {
22534            workspace_id: lease.workspace_id.clone(),
22535            session: SessionKey {
22536                instance_id: AgentInstanceId(73),
22537                generation: SessionGeneration(4),
22538            },
22539        };
22540        lease.state = ManagedWorktreeLeaseState::InUse;
22541        lease.session_holders.push(ManagedWorktreeSessionHolder {
22542            incarnation_id: shared.incarnation_id,
22543            instance_id: session.session.instance_id,
22544            generation: session.session.generation,
22545        });
22546        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22547        assert!(resolved.target.worktree_id.is_none());
22548
22549        let receipt = managed_spawn_receipt(
22550            &resolved,
22551            shared.incarnation_id,
22552            session.clone(),
22553            lease.snapshot(),
22554            None,
22555            None,
22556            None,
22557        );
22558        assert!(resolved.target.worktree_id.is_none());
22559        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22560        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22561        assert_eq!(receipt.spawn.session, session);
22562        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22563        let encoded = serde_json::to_vec(&receipt).unwrap();
22564        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22565        assert_eq!(decoded, receipt);
22566    }
22567
22568    #[tokio::test]
22569    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22570        let root = temporary_workspace_root("managed-v2-revision");
22571        let allocation = root.join("allocation");
22572        std::fs::create_dir_all(&allocation).unwrap();
22573        let shared = managed_v2_test_shared(
22574            &allocation,
22575            Some(root.join("node-state.json")),
22576            "v1",
22577        );
22578        let mut request = managed_v2_request("managed-v2-revision");
22579        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22580
22581        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22582
22583        assert_eq!(
22584            failure.code,
22585            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22586        );
22587        assert!(shared
22588            .managed_worktrees
22589            .lock()
22590            .unwrap_or_else(|poisoned| poisoned.into_inner())
22591            .records()
22592            .is_empty());
22593        assert!(shared
22594            .managed_spawn_replays
22595            .lock()
22596            .unwrap_or_else(|poisoned| poisoned.into_inner())
22597            .is_empty());
22598        std::fs::remove_dir_all(root).unwrap();
22599    }
22600
22601    #[test]
22602    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22603        let root = temporary_workspace_root("managed-v2-concurrent");
22604        let allocation = root.join("allocation");
22605        std::fs::create_dir_all(&allocation).unwrap();
22606        let shared = Arc::new(managed_v2_test_shared(
22607            &allocation,
22608            Some(root.join("node-state.json")),
22609            "v1",
22610        ));
22611        let request = managed_v2_request("managed-v2-concurrent");
22612        let barrier = Arc::new(std::sync::Barrier::new(3));
22613        let mut joins = Vec::new();
22614        for _ in 0..2 {
22615            let shared = Arc::clone(&shared);
22616            let request = request.clone();
22617            let barrier = Arc::clone(&barrier);
22618            joins.push(std::thread::spawn(move || {
22619                let digest = managed_spawn_request_digest_v2(&request).unwrap();
22620                barrier.wait();
22621                shared.acquire_durable_managed_spawn_v2(&request, digest)
22622            }));
22623        }
22624        barrier.wait();
22625        let results = joins
22626            .into_iter()
22627            .map(|join| join.join().unwrap())
22628            .collect::<Vec<_>>();
22629        assert_eq!(
22630            results
22631                .iter()
22632                .filter(|result| {
22633                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22634                })
22635                .count(),
22636            1,
22637        );
22638        assert_eq!(
22639            results
22640                .iter()
22641                .filter(|result| {
22642                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22643                })
22644                .count(),
22645            1,
22646        );
22647        assert_eq!(
22648            shared
22649                .managed_spawn_replays
22650                .lock()
22651                .unwrap_or_else(|poisoned| poisoned.into_inner())
22652                .len(),
22653            1,
22654        );
22655        assert!(shared
22656            .managed_worktrees
22657            .lock()
22658            .unwrap_or_else(|poisoned| poisoned.into_inner())
22659            .records()
22660            .is_empty());
22661        drop(shared);
22662        std::fs::remove_dir_all(root).unwrap();
22663    }
22664
22665    #[tokio::test]
22666    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22667        let root = temporary_workspace_root("managed-v2-linked-pending");
22668        let allocation = root.join("allocation");
22669        std::fs::create_dir_all(&allocation).unwrap();
22670        let state_path = root.join("node-state.json");
22671        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22672        let lease = install_managed_lease(&shared);
22673        let request = managed_v2_request("managed-v2-linked-pending");
22674        assert!(matches!(
22675            shared
22676                .acquire_durable_managed_spawn_v2(
22677                    &request,
22678                    managed_spawn_request_digest_v2(&request).unwrap(),
22679                )
22680                .unwrap(),
22681            DurableManagedSpawnReplayDecision::Reserved,
22682        ));
22683        {
22684            let _transaction = shared
22685                .state_transaction
22686                .lock()
22687                .unwrap_or_else(|poisoned| poisoned.into_inner());
22688            shared
22689                .link_durable_managed_spawn_v2_lease_locked(
22690                    &request.spawn_spec.idempotency_key,
22691                    &lease.lease_id,
22692                )
22693                .unwrap();
22694            shared.persist_state_locked().unwrap();
22695        }
22696        drop(shared);
22697
22698        let mut loaded = session_registry::load(
22699            Some(&state_path),
22700            &NodeId::new("node-terminal-test").unwrap(),
22701        )
22702        .unwrap();
22703        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22704        *reopened
22705            .managed_worktrees
22706            .lock()
22707            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22708            ManagedWorktreeRegistry::from_records(
22709                std::mem::take(&mut loaded.managed_worktrees),
22710                std::mem::take(&mut loaded.managed_worktree_tombstones),
22711            )
22712            .unwrap();
22713        *reopened
22714            .managed_spawn_replays
22715            .lock()
22716            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22717            .managed_spawn_replays
22718            .into_iter()
22719            .map(|record| (record.idempotency_key.clone(), record))
22720            .collect();
22721        let lease_count = reopened
22722            .managed_worktrees
22723            .lock()
22724            .unwrap_or_else(|poisoned| poisoned.into_inner())
22725            .records()
22726            .len();
22727
22728        let failure = reopened
22729            .spawn_managed_worktree_v2(request)
22730            .await
22731            .unwrap_err();
22732
22733        assert_eq!(
22734            failure.code,
22735            NodeFailureCode::ManagedWorktreeRecoveryRequired,
22736        );
22737        assert_eq!(
22738            reopened
22739                .managed_worktrees
22740                .lock()
22741                .unwrap_or_else(|poisoned| poisoned.into_inner())
22742                .records()
22743                .len(),
22744            lease_count,
22745        );
22746        std::fs::remove_dir_all(root).unwrap();
22747    }
22748
22749    #[tokio::test]
22750    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22751        let root = temporary_workspace_root("managed-v2-reopen");
22752        let allocation = root.join("allocation");
22753        std::fs::create_dir_all(&allocation).unwrap();
22754        let state_path = root.join("node-state.json");
22755        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22756        let lease = install_managed_lease(&shared);
22757        let request = managed_v2_request("managed-v2-reopen");
22758        let session = SessionAddress {
22759            workspace_id: lease.workspace_id.clone(),
22760            session: SessionKey {
22761                instance_id: AgentInstanceId(91),
22762                generation: SessionGeneration(1),
22763            },
22764        };
22765        let snapshot = {
22766            let mut registry = shared
22767                .managed_worktrees
22768                .lock()
22769                .unwrap_or_else(|poisoned| poisoned.into_inner());
22770            let current = registry.get_mut(&lease.lease_id).unwrap();
22771            current.state = ManagedWorktreeLeaseState::InUse;
22772            current.session_holders.push(ManagedWorktreeSessionHolder {
22773                incarnation_id: shared.incarnation_id,
22774                instance_id: session.session.instance_id,
22775                generation: session.session.generation,
22776            });
22777            current.snapshot()
22778        };
22779        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22780        let receipt = managed_spawn_receipt(
22781            &resolved,
22782            shared.incarnation_id,
22783            session,
22784            snapshot,
22785            None,
22786            None,
22787            None,
22788        );
22789        assert!(matches!(
22790            shared
22791                .acquire_durable_managed_spawn_v2(
22792                    &request,
22793                    managed_spawn_request_digest_v2(&request).unwrap(),
22794                )
22795                .unwrap(),
22796            DurableManagedSpawnReplayDecision::Reserved,
22797        ));
22798        {
22799            let _transaction = shared
22800                .state_transaction
22801                .lock()
22802                .unwrap_or_else(|poisoned| poisoned.into_inner());
22803            shared
22804                .link_durable_managed_spawn_v2_lease_locked(
22805                    &request.spawn_spec.idempotency_key,
22806                    &lease.lease_id,
22807                )
22808                .unwrap();
22809            shared.persist_state_locked().unwrap();
22810        }
22811        shared
22812            .commit_durable_managed_spawn_v2(&request, receipt.clone())
22813            .unwrap();
22814        let legacy_same_key = ManagedWorktreeSpawnRequest {
22815            spawn_spec: request.spawn_spec.clone(),
22816            worktree_profile_id: request.worktree_profile_id.clone(),
22817        };
22818        shared.remember_managed_spawn_attempt(
22819            Some(&request.spawn_spec.idempotency_key),
22820            legacy_same_key.clone(),
22821            Ok(receipt.clone()),
22822            Instant::now(),
22823        );
22824        assert!(shared
22825            .replay_managed_spawn(&legacy_same_key, Instant::now())
22826            .unwrap()
22827            .is_none());
22828        drop(shared);
22829
22830        let mut loaded = session_registry::load(
22831            Some(&state_path),
22832            &NodeId::new("node-terminal-test").unwrap(),
22833        )
22834        .unwrap();
22835        assert_eq!(loaded.managed_spawn_replays.len(), 1);
22836        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22837        *reopened
22838            .managed_worktrees
22839            .lock()
22840            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22841            ManagedWorktreeRegistry::from_records(
22842                std::mem::take(&mut loaded.managed_worktrees),
22843                std::mem::take(&mut loaded.managed_worktree_tombstones),
22844            )
22845            .unwrap();
22846        *reopened
22847            .managed_spawn_replays
22848            .lock()
22849            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22850            .managed_spawn_replays
22851            .into_iter()
22852            .map(|record| (record.idempotency_key.clone(), record))
22853            .collect();
22854        let lease_count_before = reopened
22855            .managed_worktrees
22856            .lock()
22857            .unwrap_or_else(|poisoned| poisoned.into_inner())
22858            .records()
22859            .len();
22860
22861        assert_eq!(
22862            reopened
22863                .spawn_managed_worktree_v2(request.clone())
22864                .await
22865                .unwrap(),
22866            receipt,
22867        );
22868        assert_eq!(
22869            reopened
22870                .managed_worktrees
22871                .lock()
22872                .unwrap_or_else(|poisoned| poisoned.into_inner())
22873                .records()
22874                .len(),
22875            lease_count_before,
22876        );
22877        let mut changed = request;
22878        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22879        let failure = reopened
22880            .spawn_managed_worktree_v2(changed)
22881            .await
22882            .unwrap_err();
22883        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22884        assert_eq!(
22885            reopened
22886                .managed_worktrees
22887                .lock()
22888                .unwrap_or_else(|poisoned| poisoned.into_inner())
22889                .records()
22890                .len(),
22891            lease_count_before,
22892        );
22893        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22894        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22895        let failure = reopened
22896            .spawn_managed_worktree_v2(new_key)
22897            .await
22898            .unwrap_err();
22899        assert_eq!(
22900            failure.code,
22901            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22902        );
22903        assert_eq!(
22904            reopened
22905                .managed_worktrees
22906                .lock()
22907                .unwrap_or_else(|poisoned| poisoned.into_inner())
22908                .records()
22909                .len(),
22910            lease_count_before,
22911        );
22912        assert_eq!(
22913            reopened
22914                .managed_spawn_replays
22915                .lock()
22916                .unwrap_or_else(|poisoned| poisoned.into_inner())
22917                .len(),
22918            1,
22919        );
22920        std::fs::remove_dir_all(root).unwrap();
22921    }
22922
22923    #[test]
22924    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22925        let shared = terminal_test_shared();
22926        let request = ManagedWorktreeSpawnRequest {
22927            spawn_spec: spawn_spec_fixture(),
22928            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22929        };
22930        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22931        shared.remember_managed_spawn(
22932            request.clone(),
22933            Err(expected.clone()),
22934            Instant::now(),
22935        );
22936
22937        assert_eq!(
22938            shared
22939                .replay_managed_spawn(&request, Instant::now())
22940                .unwrap()
22941                .unwrap(),
22942            Err(expected),
22943        );
22944        assert!(shared
22945            .managed_spawn_replays
22946            .lock()
22947            .unwrap_or_else(|poisoned| poisoned.into_inner())
22948            .is_empty());
22949    }
22950
22951    #[tokio::test]
22952    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22953        let shared = terminal_test_shared();
22954        let lease = install_managed_lease(&shared);
22955        let raw = shared.spawn_session(
22956            lease.workspace_id.clone(),
22957            agent("claude"),
22958            SessionMode::Pty,
22959            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22960            None,
22961        ).await.unwrap_err();
22962        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22963        let mut spec = spawn_spec_fixture();
22964        spec.target.workspace_id = lease.workspace_id.clone();
22965        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22966        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22967        let mut selected = spawn_spec_fixture();
22968        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22969        selected.target.worktree_id = Some(lease.workspace_id);
22970        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22971        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22972        assert!(shared.handle.snapshot().sessions.is_empty());
22973    }
22974
22975    #[test]
22976    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22977        let shared = terminal_test_shared();
22978        let lease = install_managed_lease(&shared);
22979        let snapshot = snapshot_for_wire(
22980            &shared, true, true, true, false, true, true, false, false, true,
22981        );
22982        assert!(snapshot.managed_worktrees.is_empty());
22983        assert!(snapshot.workspaces.iter().all(|workspace| {
22984            workspace.managed_worktree_profiles.is_none()
22985        }));
22986        let mut reply = ResponseEnvelope {
22987            request_id: 1,
22988            result: Ok(NodeResponse::Resync {
22989                event_sequence: 1,
22990                oldest_available_sequence: 1,
22991                snapshot: shared.snapshot(),
22992                events: vec![NodeEventEnvelope {
22993                    sequence: 1,
22994                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22995                }],
22996            }),
22997        };
22998        project_response_without_managed_worktrees(&mut reply);
22999        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23000            panic!("projection changed the response kind");
23001        };
23002        assert!(snapshot.managed_worktrees.is_empty());
23003        assert!(events.is_empty());
23004        let mut git = git_snapshot_for_parser();
23005        git.branch = Some(lease.branch.clone());
23006        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23007            lease_id: lease.lease_id.clone(),
23008            source_workspace_id: lease.source_workspace_id.clone(),
23009            branch: lease.branch.clone(),
23010            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23011            active_session_count: 1,
23012            managed_record_count: 0,
23013        });
23014        let mut inspection_reply = ResponseEnvelope {
23015            request_id: 2,
23016            result: Ok(NodeResponse::WorkspaceInspected {
23017                inspection: WorkspaceInspection {
23018                    workspace_id: lease.workspace_id.clone(),
23019                    entries: Vec::new(),
23020                    tree_truncated: false,
23021                    git,
23022                    truncation: None,
23023                },
23024            }),
23025        };
23026        project_response_without_managed_worktrees(&mut inspection_reply);
23027        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23028            panic!("projection changed the inspection response kind");
23029        };
23030        assert!(inspection.git.managed_worktree.is_none());
23031        let failure = managed_git_worktree_failure(GitWorktreeError {
23032            kind: GitWorktreeErrorKind::Failed,
23033            message: r"git failed at C:\private\managed-target".to_owned(),
23034        });
23035        assert!(!failure.message.contains("private"));
23036        assert!(!failure.message.contains("managed-target"));
23037    }
23038
23039    #[tokio::test]
23040    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23041        let root = temporary_workspace_root("managed-restart");
23042        let source = root.join("source");
23043        let allocation = root.join("allocation");
23044        std::fs::create_dir_all(&source).unwrap();
23045        std::fs::create_dir_all(&allocation).unwrap();
23046        run_test_git(&source, &["init"]);
23047        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23048        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23049        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23050        run_test_git(&source, &["add", "seed.txt"]);
23051        run_test_git(&source, &["commit", "-m", "seed"]);
23052        let profile = ManagedWorktreeProfile::new(
23053            WorktreeProfileId::new("default").unwrap(),
23054            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23055            &allocation,
23056            "gate4agent",
23057            "HEAD",
23058            ManagedWorktreeRetention::Retain,
23059        ).unwrap();
23060        let workspace = WorkspaceConfig::new(
23061            WorkspaceId::new("primary").unwrap(),
23062            &source,
23063        ).unwrap()
23064            .with_worktree_service_mode(WorktreeServiceMode::Managed)
23065            .with_managed_worktree_profile(profile.clone()).unwrap();
23066        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23067        let source_text = workspace.canonical_root().to_owned();
23068        let target_text = target.to_string_lossy().into_owned();
23069        let created = create_git_worktree(
23070            &source_text,
23071            &target_text,
23072            "gate4agent/mw-restart",
23073            Some("HEAD"),
23074        ).await.unwrap();
23075        let lease = ManagedWorktreeLeaseRecord {
23076            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23077            source_workspace_id: WorkspaceId::new("primary").unwrap(),
23078            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23079            profile_id: profile.profile_id().clone(),
23080            profile_revision: profile.revision().clone(),
23081            target_root: created.path.clone(),
23082            branch: "gate4agent/mw-restart".to_owned(),
23083            base_commit: created.head.clone(),
23084            expected_head: None,
23085            retention: ManagedWorktreeRetention::Retain,
23086            state: ManagedWorktreeLeaseState::Allocating,
23087            session_holders: Vec::new(),
23088            record_holders: Vec::new(),
23089            cleanup_failure: None,
23090            created_at_unix_ms: 1,
23091            updated_at_unix_ms: 1,
23092        };
23093        let catalog = active_registry().unwrap();
23094        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23095        let shared = NodeShared::new_with_incarnation(
23096            handle,
23097            "fixture-token".to_owned(),
23098            NodeId::new("node-managed-restart").unwrap(),
23099            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23100            vec![workspace],
23101            vec![agent("claude")],
23102            ProviderRuntimeStatuses::default(),
23103            None,
23104            Vec::new(),
23105            Vec::new(),
23106            SpawnProfileRegistry::default(),
23107            None,
23108            None,
23109            None,
23110            None,
23111            Vec::new(),
23112            vec![lease.clone()],
23113            Vec::new(),
23114            Vec::new(),
23115            None,
23116            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23117        );
23118        shared.reconcile_managed_worktrees().await;
23119        let retained = shared.managed_worktrees.lock()
23120            .unwrap_or_else(|poisoned| poisoned.into_inner())
23121            .get(&lease.lease_id).unwrap().clone();
23122        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23123        assert!(shared.workspaces.read()
23124            .unwrap_or_else(|poisoned| poisoned.into_inner())
23125            .contains_key(&lease.workspace_id));
23126        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23127        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23128        assert!(!Path::new(&created.path).exists());
23129        std::fs::remove_dir_all(root).unwrap();
23130    }
23131
23132    fn run_test_git(root: &Path, arguments: &[&str]) {
23133        let output = std::process::Command::new("git")
23134            .arg("-C").arg(root).args(arguments).output().unwrap();
23135        assert!(
23136            output.status.success(),
23137            "git {:?} failed: {}",
23138            arguments,
23139            String::from_utf8_lossy(&output.stderr),
23140        );
23141    }
23142
23143    #[tokio::test]
23144    async fn spawn_spec_validation_fails_before_session_mutation() {
23145        let shared = terminal_test_shared();
23146        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23147            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23148        }));
23149        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23150            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23151        }));
23152        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23153            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23154        }));
23155
23156        let mut target_mismatch = spawn_spec_fixture();
23157        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23158        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23159        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23160
23161        let mut unknown_profile = spawn_spec_fixture();
23162        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23163        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23164        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23165        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23166
23167        let mut revision_mismatch = spawn_spec_fixture();
23168        revision_mismatch.idempotency_key =
23169            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23170        revision_mismatch.expected_profile_revision =
23171            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23172        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23173        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23174
23175        let mut unsupported_materializer = spawn_spec_fixture();
23176        unsupported_materializer.idempotency_key =
23177            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23178        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23179            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23180        };
23181        let failure = shared
23182            .spawn_from_spec(unsupported_materializer)
23183            .await
23184            .unwrap_err();
23185        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23186
23187        let failure = shared
23188            .spawn_session_with_deadline(
23189                WorkspaceId::new("primary").unwrap(),
23190                agent("claude"),
23191                SessionMode::Pty,
23192                gate4agent_types::TerminalSize {
23193                    rows: 24,
23194                    columns: 80,
23195                },
23196                None,
23197                None,
23198                None,
23199                None,
23200                None,
23201                None,
23202                SpawnRecordPolicy::ProviderIdentityOnly,
23203                Some(Instant::now()),
23204                &[],
23205                None,
23206                ApprovalLevel::default(),
23207                None,
23208                None,
23209            )
23210            .await
23211            .unwrap_err();
23212        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23213        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23214        assert!(shared.handle.snapshot().sessions.is_empty());
23215    }
23216
23217    #[test]
23218    fn worktree_selection_requires_its_separate_negotiated_capability() {
23219        let mut spec = spawn_spec_fixture();
23220        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23221        let request = NodeRequest::SpawnSpec { spec };
23222        let spawn_spec_capability =
23223            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23224        assert!(request_uses_unnegotiated_capability(
23225            &request,
23226            &[spawn_spec_capability.clone()],
23227        ));
23228        let worktree_capability =
23229            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23230        assert!(request_uses_unnegotiated_capability(
23231            &request,
23232            &[spawn_spec_capability.clone(), worktree_capability.clone()],
23233        ));
23234        let profile_revision_capability =
23235            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23236        assert!(request_uses_unnegotiated_capability(
23237            &request,
23238            &[
23239                spawn_spec_capability.clone(),
23240                worktree_capability.clone(),
23241                profile_revision_capability.clone(),
23242            ],
23243        ));
23244        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
23245        // `Inherit`, not `Clear` — the profile's own defaults might still
23246        // materialize a bundle or a context pack, so both capabilities stay
23247        // conservatively required until negotiated (see
23248        // `requires_session_bundle_materialization_capability` /
23249        // `requires_history_context_pack_capability`).
23250        let bundle_materialization_capability =
23251            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23252        assert!(request_uses_unnegotiated_capability(
23253            &request,
23254            &[
23255                spawn_spec_capability.clone(),
23256                worktree_capability.clone(),
23257                profile_revision_capability.clone(),
23258                bundle_materialization_capability.clone(),
23259            ],
23260        ));
23261        let history_context_pack_capability =
23262            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23263        assert!(!request_uses_unnegotiated_capability(
23264            &request,
23265            &[
23266                spawn_spec_capability,
23267                worktree_capability,
23268                profile_revision_capability,
23269                bundle_materialization_capability,
23270                history_context_pack_capability,
23271            ],
23272        ));
23273    }
23274
23275    #[tokio::test]
23276    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23277        let mut shared = terminal_test_shared();
23278        let spec = spawn_spec_fixture();
23279        let receipt = shared
23280            .resolve_spawn_spec(&spec)
23281            .unwrap()
23282            .receipt(shared.incarnation_id, terminal_address(1));
23283        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23284
23285        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23286        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23287        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23288
23289        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23290
23291        let mut revision_conflicting = spec.clone();
23292        revision_conflicting.expected_profile_revision =
23293            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23294        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23295        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23296
23297        let mut stale_cache_miss = spec.clone();
23298        stale_cache_miss.idempotency_key =
23299            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23300        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23301        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23302
23303        let mut conflicting = spec;
23304        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23305        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23306        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23307        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23308        assert!(shared.handle.snapshot().sessions.is_empty());
23309    }
23310
23311    #[tokio::test]
23312    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23313        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23314        let mut spec = spawn_spec_fixture();
23315        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23316        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23317        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23318
23319        let failure = shared
23320            .create_worktree(
23321                WorkspaceId::new("primary").unwrap(),
23322                WorkspaceId::new("new-worktree").unwrap(),
23323                "not-an-absolute-path".to_owned(),
23324                "invalid branch".to_owned(),
23325                None,
23326            )
23327            .await
23328            .unwrap_err();
23329        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23330
23331        let failure = shared
23332            .remove_worktree(
23333                WorkspaceId::new("primary").unwrap(),
23334                "not-an-absolute-path".to_owned(),
23335            )
23336            .await
23337            .unwrap_err();
23338        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23339        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23340        assert!(shared.handle.snapshot().sessions.is_empty());
23341    }
23342
23343    #[tokio::test]
23344    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23345        let root = temporary_workspace_root("manual-worktree-compensation");
23346        let source = root.join("source");
23347        let target = root.join("target");
23348        let invalid_state_parent = root.join("state-parent-is-a-file");
23349        let invalid_state_path = invalid_state_parent.join("state.json");
23350        std::fs::create_dir_all(&source).unwrap();
23351        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23352        run_test_git(&source, &["init"]);
23353        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23354        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23355        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23356        run_test_git(&source, &["add", "seed.txt"]);
23357        run_test_git(&source, &["commit", "-m", "seed"]);
23358
23359        let catalog = active_registry().unwrap();
23360        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23361        let workspace = WorkspaceConfig::new(
23362            WorkspaceId::new("primary").unwrap(),
23363            &source,
23364        ).unwrap();
23365        let mut shared = NodeShared::new(
23366            handle,
23367            "fixture-token".to_owned(),
23368            NodeId::new("node-worktree-compensation").unwrap(),
23369            vec![workspace],
23370            vec![agent("claude")],
23371        );
23372        shared.state_path = Some(invalid_state_path);
23373
23374        let error = shared.create_worktree(
23375            WorkspaceId::new("primary").unwrap(),
23376            WorkspaceId::new("compensated").unwrap(),
23377            target.to_string_lossy().into_owned(),
23378            "gate4agent/compensated".to_owned(),
23379            Some("HEAD".to_owned()),
23380        ).await.unwrap_err();
23381
23382        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23383        assert!(!target.exists());
23384        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23385            .iter()
23386            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23387        std::fs::remove_dir_all(root).unwrap();
23388    }
23389
23390    #[test]
23391    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23392        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23393            NativeGitWorktreeSnapshot {
23394                path: path.to_owned(),
23395                head: "0123456789abcdef".to_owned(),
23396                branch: Some("refs/heads/test".to_owned()),
23397                is_bare,
23398                is_main,
23399                locked: false,
23400                lock_reason: None,
23401                prunable: false,
23402                prunable_reason: None,
23403                workspace_id: None,
23404            }
23405        }
23406
23407        let source = r"C:\repo";
23408        let selected = r"C:\repo-worktree";
23409        let listed = vec![
23410            worktree(source, true, false),
23411            worktree(selected, false, false),
23412        ];
23413        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23414
23415        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23416        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23417
23418        let bare = vec![
23419            worktree(source, true, false),
23420            worktree(selected, false, true),
23421        ];
23422        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23423        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23424
23425        let foreign_failure =
23426            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23427        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23428    }
23429
23430    fn terminal_frame(sequence: u64) -> TerminalFrame {
23431        TerminalFrame {
23432            sequence,
23433            size: gate4agent_types::TerminalSize {
23434                rows: 24,
23435                columns: 80,
23436            },
23437            cursor_row: 2,
23438            cursor_column: 3,
23439            contents: format!("frame-{sequence}"),
23440            formatted: vec![1, 2, 3],
23441            scrollback_formatted: Vec::new(),
23442            alternate_screen: false,
23443            mouse_protocol_enabled: false,
23444            mouse_protocol_encoding: Default::default(),
23445            produced_at_unix_ms: 0,
23446            screen_state: gate4agent_types::PtyScreenState::default(),
23447            bracketed_paste: None,
23448        }
23449    }
23450
23451    #[test]
23452    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23453        let shared = terminal_test_shared();
23454        let mut receiver = shared.terminal_event_tx.subscribe();
23455        let original = terminal_address(1);
23456
23457        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23458        let first = receiver.try_recv().unwrap();
23459        assert_eq!(first.len(), 1);
23460        assert!(matches!(
23461            &first[0].event,
23462            NodeEvent::TerminalFrame { address, frame }
23463                if address == &original && frame.sequence == 7
23464        ));
23465
23466        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23467        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23468        assert!(matches!(
23469            receiver.try_recv(),
23470            Err(broadcast::error::TryRecvError::Empty)
23471        ));
23472
23473        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23474        let advanced = receiver.try_recv().unwrap();
23475        assert_eq!(advanced[0].sequence, 2);
23476
23477        let resumed = terminal_address(2);
23478        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23479        let reset = receiver.try_recv().unwrap();
23480        assert_eq!(reset[0].sequence, 3);
23481        assert!(matches!(
23482            &reset[0].event,
23483            NodeEvent::TerminalFrame { address, frame }
23484                if address == &resumed && frame.sequence == 1
23485        ));
23486        shared.clear_terminal_frame_watermark(&resumed);
23487        assert!(!shared
23488            .terminal_frame_watermarks
23489            .lock()
23490            .unwrap_or_else(|poisoned| poisoned.into_inner())
23491            .contains_key(&resumed.session.instance_id));
23492    }
23493
23494    #[test]
23495    fn terminal_frame_batches_replace_without_entering_node_event_history() {
23496        let shared = terminal_test_shared();
23497        let mut receiver = shared.terminal_event_tx.subscribe();
23498        let address = terminal_address(1);
23499        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23500
23501        for sequence in 1..=count {
23502            shared.publish_terminal_frame_candidates(vec![(
23503                address.clone(),
23504                terminal_frame(sequence),
23505            )]);
23506        }
23507
23508        let history = shared
23509            .history
23510            .lock()
23511            .unwrap_or_else(|poisoned| poisoned.into_inner());
23512        assert!(history.events.is_empty());
23513        assert_eq!(history.last_sequence, count);
23514        assert_eq!(history.replay_floor_sequence, 1);
23515        drop(history);
23516        let NodeResponse::Resync {
23517            event_sequence,
23518            oldest_available_sequence,
23519            events,
23520            ..
23521        } = shared.resync(0) else {
23522            unreachable!("resync helper returned another response")
23523        };
23524        assert_eq!(event_sequence, count);
23525        assert_eq!(oldest_available_sequence, 1);
23526        assert!(events.is_empty());
23527        assert!(matches!(
23528            resync_required_event(&shared).event,
23529            NodeEvent::ResyncRequired {
23530                oldest_available_sequence: 1,
23531            }
23532        ));
23533        assert!(matches!(
23534            receiver.try_recv(),
23535            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23536        ));
23537        let latest = receiver.try_recv().unwrap();
23538        assert_eq!(latest.len(), 1);
23539        assert_eq!(latest[0].sequence, count);
23540    }
23541
23542    #[test]
23543    fn real_durable_eviction_advances_authoritative_replay_floor() {
23544        let shared = terminal_test_shared();
23545        for _ in 0..=NODE_EVENT_HISTORY_MAX {
23546            shared.publish(NodeEvent::ControllerChanged { controller: None });
23547        }
23548
23549        let NodeResponse::Resync {
23550            event_sequence,
23551            oldest_available_sequence,
23552            events,
23553            ..
23554        } = shared.resync(0) else {
23555            unreachable!("resync helper returned another response")
23556        };
23557        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23558        assert_eq!(oldest_available_sequence, 2);
23559        assert_eq!(events.first().map(|event| event.sequence), Some(2));
23560        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23561        assert!(matches!(
23562            resync_required_event(&shared).event,
23563            NodeEvent::ResyncRequired {
23564                oldest_available_sequence: 2,
23565            }
23566        ));
23567    }
23568
23569    #[test]
23570    fn legacy_terminal_frame_connection_has_no_live_subscription() {
23571        let shared = terminal_test_shared();
23572        let without_capability = terminal_event_subscription(&shared, false);
23573        let with_capability = terminal_event_subscription(&shared, true);
23574        assert!(without_capability.is_none());
23575        assert!(with_capability.is_some());
23576        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23577            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23578        }));
23579    }
23580
23581    #[test]
23582    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23583        let mut pending = Vec::new();
23584        let mut event_sequence = 0_u64;
23585        for frame_sequence in 1..=4 {
23586            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23587                event_sequence += 1;
23588                let mut address = terminal_address(1);
23589                address.session.instance_id = AgentInstanceId(instance);
23590                queue_connection_event(
23591                    &mut pending,
23592                    NodeEventEnvelope {
23593                        sequence: event_sequence,
23594                        event: NodeEvent::TerminalFrame {
23595                            address,
23596                            frame: terminal_frame(frame_sequence),
23597                        },
23598                    },
23599                    0,
23600                );
23601            }
23602        }
23603        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23604        pending.sort_unstable_by_key(|event| event.sequence);
23605
23606        let mut uninterrupted = pending.clone();
23607        let first_burst = connection_event_burst_len(uninterrupted.len());
23608        uninterrupted.drain(..first_burst);
23609        let second_burst = connection_event_burst_len(uninterrupted.len());
23610        uninterrupted.drain(..second_burst);
23611        assert!(uninterrupted.is_empty());
23612
23613        let (frame_tx, mut frame_rx) =
23614            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23615        frame_tx
23616            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23617                request_id: 77,
23618                request: NodeRequest::Snapshot,
23619            })))
23620            .unwrap();
23621        let burst_len = connection_event_burst_len(pending.len());
23622        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23623        pending.drain(..burst_len);
23624        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23625        assert!(matches!(
23626            frame_rx.try_recv(),
23627            Ok(Ok(ClientFrame::Request(RequestEnvelope {
23628                request_id: 77,
23629                request: NodeRequest::Snapshot,
23630            })))
23631        ));
23632    }
23633
23634    #[test]
23635    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23636        let mut pending = Vec::new();
23637        for sequence in [7_u64, 8] {
23638            let mut address = terminal_address(1);
23639            address.session.instance_id = AgentInstanceId(sequence);
23640            queue_connection_event(
23641                &mut pending,
23642                NodeEventEnvelope {
23643                    sequence,
23644                    event: NodeEvent::TerminalFrame {
23645                        address,
23646                        frame: terminal_frame(sequence),
23647                    },
23648                },
23649                0,
23650            );
23651        }
23652        let mut discard_through = 0;
23653        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23654        assert!(pending.is_empty());
23655
23656        let replayed = NodeEventEnvelope {
23657            sequence: 8,
23658            event: NodeEvent::TerminalFrame {
23659                address: terminal_address(1),
23660                frame: terminal_frame(8),
23661            },
23662        };
23663        let advanced = NodeEventEnvelope {
23664            sequence: 9,
23665            event: NodeEvent::TerminalFrame {
23666                address: terminal_address(2),
23667                frame: terminal_frame(1),
23668            },
23669        };
23670        queue_connection_event_batch(
23671            &mut pending,
23672            &[replayed, advanced.clone()],
23673            discard_through,
23674        );
23675        assert_eq!(pending, vec![advanced]);
23676    }
23677
23678    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23679        ManagedSessionRecord {
23680            record_id: SessionRecordId::new(record_id).unwrap(),
23681            display_name: format!("{provider} record"),
23682            provider: agent(provider),
23683            mode: SessionMode::Pty,
23684            state: ManagedSessionState::Unavailable,
23685            workspace_id: WorkspaceId::new("primary").unwrap(),
23686            canonical_root: opaque_windows_path(
23687                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23688            ),
23689            provider_session: None,
23690            active_session: None,
23691            environment_profile: None,
23692            bundle: None,
23693            context_id: None,
23694            context: None,
23695            exported_context: None,
23696            task_binding: None,
23697            created_at_unix_ms: 1,
23698            updated_at_unix_ms: 1,
23699            last_error: None,
23700        }
23701    }
23702
23703    fn retention_test_record(
23704        workspace_id: &str,
23705        record_id: &str,
23706        state: ManagedSessionState,
23707        updated_at_unix_ms: u64,
23708    ) -> ManagedSessionRecord {
23709        ManagedSessionRecord {
23710            record_id: SessionRecordId::new(record_id).unwrap(),
23711            display_name: format!("{record_id} display"),
23712            provider: agent("claude"),
23713            mode: SessionMode::Pty,
23714            state,
23715            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23716            canonical_root: opaque_windows_path(
23717                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23718            ),
23719            provider_session: None,
23720            active_session: None,
23721            environment_profile: None,
23722            bundle: None,
23723            context_id: None,
23724            context: None,
23725            exported_context: None,
23726            task_binding: None,
23727            created_at_unix_ms: updated_at_unix_ms,
23728            updated_at_unix_ms,
23729            last_error: None,
23730        }
23731    }
23732
23733    /// Two workspaces of three `Unavailable` records each, staggered by
23734    /// age, plus one never-touchable decoy per non-`Unavailable` state in
23735    /// `workspace-a` (all as old as that workspace's oldest real record --
23736    /// if the sweep's state gate ever leaked, these would be the first
23737    /// thing it wrongly retired). Ages, oldest to newest:
23738    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
23739    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
23740    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23741        vec![
23742            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23743            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23744            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23745            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23746            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23747            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23748            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23749            retention_test_record(
23750                "workspace-a",
23751                "decoy-pending",
23752                ManagedSessionState::IdentityPending,
23753                now - 500_000,
23754            ),
23755            retention_test_record(
23756                "workspace-a",
23757                "decoy-dormant",
23758                ManagedSessionState::Dormant,
23759                now - 500_000,
23760            ),
23761        ]
23762    }
23763
23764    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23765        let root = temporary_workspace_root("session-record-retention");
23766        std::fs::create_dir_all(&root).unwrap();
23767        let catalog = active_registry().unwrap();
23768        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23769        let workspace_a =
23770            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23771        let workspace_b =
23772            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23773        let mut shared = NodeShared::new(
23774            handle,
23775            "fixture-token".to_owned(),
23776            NodeId::new("node-retention-test").unwrap(),
23777            vec![workspace_a, workspace_b],
23778            vec![agent("claude")],
23779        );
23780        shared.state_path = Some(root.join("node-state-v10.json"));
23781        let now = unix_time_ms();
23782        for record in retention_fixture_records(now) {
23783            shared.insert_record(record).unwrap();
23784        }
23785        shared
23786            .persist_state_locked()
23787            .expect("fixture-only, empty-history persist against a fresh temp state path");
23788        (shared, runtime, root, now)
23789    }
23790
23791    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23792        shared
23793            .history
23794            .lock()
23795            .unwrap_or_else(|poisoned| poisoned.into_inner())
23796            .events
23797            .iter()
23798            .filter_map(|envelope| match &envelope.event {
23799                NodeEvent::SessionRecordRemoved { record_id } => {
23800                    Some(record_id.as_str().to_owned())
23801                }
23802                _ => None,
23803            })
23804            .collect()
23805    }
23806
23807    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23808        shared
23809            .session_records
23810            .lock()
23811            .unwrap_or_else(|poisoned| poisoned.into_inner())
23812            .records
23813            .keys()
23814            .map(|record_id| record_id.as_str().to_owned())
23815            .collect()
23816    }
23817
23818    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23819        values.iter().map(|value| (*value).to_owned()).collect()
23820    }
23821
23822    #[tokio::test]
23823    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23824        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23825        shared.session_record_retention = SessionRecordRetentionConfig {
23826            age_ms: 0,
23827            keep_per_workspace: 1,
23828        };
23829        shared.retire_unavailable_session_records().await;
23830
23831        assert_eq!(
23832            retention_remaining_record_ids(&shared),
23833            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23834        );
23835        assert_eq!(
23836            retention_removed_record_ids(&shared),
23837            owned(&["a1", "a2", "b1", "b2"]),
23838        );
23839        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23840
23841        drop(shared);
23842        drop(runtime);
23843        std::fs::remove_dir_all(root).unwrap();
23844    }
23845
23846    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
23847    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
23848    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
23849    /// 200s old against a 250s threshold) even though it fails the keep-N
23850    /// test too, proving retirement needs BOTH, not either.
23851    #[tokio::test]
23852    async fn session_record_retention_both_set_retires_only_the_intersection() {
23853        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23854        shared.session_record_retention = SessionRecordRetentionConfig {
23855            age_ms: 250_000,
23856            keep_per_workspace: 1,
23857        };
23858        shared.retire_unavailable_session_records().await;
23859
23860        assert_eq!(
23861            retention_remaining_record_ids(&shared),
23862            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23863        );
23864        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23865        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23866
23867        drop(shared);
23868        drop(runtime);
23869        std::fs::remove_dir_all(root).unwrap();
23870    }
23871
23872    #[tokio::test]
23873    async fn session_record_retention_both_disabled_is_a_no_op() {
23874        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23875        shared.session_record_retention = SessionRecordRetentionConfig::default();
23876        shared.retire_unavailable_session_records().await;
23877
23878        assert_eq!(
23879            retention_remaining_record_ids(&shared),
23880            owned(&[
23881                "a1", "a2", "a3", "b1", "b2", "b3",
23882                "decoy-live", "decoy-pending", "decoy-dormant",
23883            ]),
23884        );
23885        assert!(retention_removed_record_ids(&shared).is_empty());
23886        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23887
23888        drop(shared);
23889        drop(runtime);
23890        std::fs::remove_dir_all(root).unwrap();
23891    }
23892
23893    #[test]
23894    fn session_record_context_export_selects_one_exact_history_identity() {
23895        let candidates = vec![
23896            HistoryCandidateSummary {
23897                id: "candidate-other".to_owned(),
23898                session_id_hint: "provider-other".to_owned(),
23899                modified_at_unix_ms: Some(1),
23900            },
23901            HistoryCandidateSummary {
23902                id: "candidate-exact".to_owned(),
23903                session_id_hint: "provider-exact".to_owned(),
23904                modified_at_unix_ms: Some(2),
23905            },
23906        ];
23907        assert_eq!(
23908            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23909            "candidate-exact",
23910        );
23911    }
23912
23913    #[test]
23914    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23915        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23916            id: id.to_owned(),
23917            session_id_hint: hint.to_owned(),
23918            modified_at_unix_ms: None,
23919        };
23920        let missing = select_history_candidate(
23921            &[candidate("candidate-other", "provider-other")],
23922            Some("provider-exact"),
23923        )
23924        .unwrap_err();
23925        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23926        let ambiguous = select_history_candidate(
23927            &[
23928                candidate("candidate-a", "provider-exact"),
23929                candidate("candidate-b", "provider-exact"),
23930            ],
23931            Some("provider-exact"),
23932        )
23933        .unwrap_err();
23934        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23935    }
23936
23937    /// The PTY-sidecar-observation shape (no fleet provider today, but
23938    /// capability-keyed rather than vendor-locked) never carries a verified
23939    /// `provider_session`; its record-bound export instead falls back to
23940    /// "exactly one discovered candidate" (see
23941    /// `export_context_pack_for_session_record_inner`). This is the same
23942    /// ambiguity refusal as the identity-filtered path, just without an
23943    /// identity to filter by.
23944    #[test]
23945    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23946        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23947            id: id.to_owned(),
23948            session_id_hint: hint.to_owned(),
23949            modified_at_unix_ms: None,
23950        };
23951        assert_eq!(
23952            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23953                .unwrap(),
23954            "candidate-sole",
23955        );
23956        let missing = select_history_candidate(&[], None).unwrap_err();
23957        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23958        let ambiguous = select_history_candidate(
23959            &[
23960                candidate("candidate-a", "sidecar-session-a"),
23961                candidate("candidate-b", "sidecar-session-b"),
23962            ],
23963            None,
23964        )
23965        .unwrap_err();
23966        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23967    }
23968
23969    #[test]
23970    fn session_record_context_export_revalidation_rejects_record_change() {
23971        let session = terminal_address(1);
23972        let identity = ProviderSessionIdentity {
23973            key: ProviderSessionKey::SessionId,
23974            id: "provider-exact".to_owned(),
23975            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23976        };
23977        let mut expected = record("claude", "record-context-export");
23978        expected.state = ManagedSessionState::Live;
23979        expected.provider_session = Some(identity.clone());
23980        expected.active_session = Some(session.clone());
23981        let binding = SessionBinding {
23982            workspace_id: session.workspace_id.clone(),
23983            generation: session.session.generation,
23984            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23985            pending_resume: None,
23986            record_id: Some(expected.record_id.clone()),
23987            managed_worktree_lease_id: None,
23988            environment_profile: None,
23989            bundle: None,
23990            context: None,
23991            materialization_id: None,
23992        };
23993        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23994        assert!(session_record_context_export_binding_is_exact(
23995            &expected,
23996            &expected,
23997            Some(&identity),
23998            &session,
23999            &binding,
24000            &expected.provider,
24001            &current_root,
24002            false,
24003        ));
24004        let mut changed = expected.clone();
24005        changed.updated_at_unix_ms += 1;
24006        assert!(!session_record_context_export_binding_is_exact(
24007            &expected,
24008            &changed,
24009            Some(&identity),
24010            &session,
24011            &binding,
24012            &expected.provider,
24013            &current_root,
24014            false,
24015        ));
24016        // A same-instant timestamp bump alone (state and active_session
24017        // otherwise unchanged) is not the clean-detachment transition
24018        // either, even with the reactive flag on: only the exact Live ->
24019        // Dormant + active_session cleared shape qualifies.
24020        assert!(!session_record_context_export_binding_is_exact(
24021            &expected,
24022            &changed,
24023            Some(&identity),
24024            &session,
24025            &binding,
24026            &expected.provider,
24027            &current_root,
24028            true,
24029        ));
24030        let mut detached = expected.clone();
24031        detached.state = ManagedSessionState::Dormant;
24032        detached.active_session = None;
24033        detached.updated_at_unix_ms += 1;
24034        assert!(!session_record_context_export_binding_is_exact(
24035            &expected,
24036            &detached,
24037            Some(&identity),
24038            &session,
24039            &binding,
24040            &expected.provider,
24041            &current_root,
24042            false,
24043        ));
24044        assert!(session_record_context_export_binding_is_exact(
24045            &expected,
24046            &detached,
24047            Some(&identity),
24048            &session,
24049            &binding,
24050            &expected.provider,
24051            &current_root,
24052            true,
24053        ));
24054    }
24055
24056    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
24057    /// for a record whose provider never carries a verified identity (the
24058    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
24059    /// declares one today): `identity` is `None` throughout, and the
24060    /// exactness check falls back to comparing `provider_session` itself,
24061    /// which must still be `None` on both sides.
24062    #[test]
24063    fn session_record_context_export_binding_is_exact_without_identity() {
24064        let session = terminal_address(1);
24065        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24066        expected.state = ManagedSessionState::Live;
24067        expected.active_session = Some(session.clone());
24068        let binding = SessionBinding {
24069            workspace_id: session.workspace_id.clone(),
24070            generation: session.session.generation,
24071            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24072            pending_resume: None,
24073            record_id: Some(expected.record_id.clone()),
24074            managed_worktree_lease_id: None,
24075            environment_profile: None,
24076            bundle: None,
24077            context: None,
24078            materialization_id: None,
24079        };
24080        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24081        assert!(session_record_context_export_binding_is_exact(
24082            &expected,
24083            &expected,
24084            None,
24085            &session,
24086            &binding,
24087            &expected.provider,
24088            &current_root,
24089            false,
24090        ));
24091        // A record that gained an identity between the initial lookup and
24092        // this recheck no longer matches the `None` this shape expects to
24093        // keep seeing -- treated the same as any other mid-export drift.
24094        let mut drifted = expected.clone();
24095        drifted.provider_session = Some(ProviderSessionIdentity {
24096            key: ProviderSessionKey::SessionId,
24097            id: "unexpected-identity".to_owned(),
24098            transcript_path: None,
24099        });
24100        assert!(!session_record_context_export_binding_is_exact(
24101            &expected,
24102            &drifted,
24103            None,
24104            &session,
24105            &binding,
24106            &expected.provider,
24107            &current_root,
24108            false,
24109        ));
24110    }
24111
24112    #[test]
24113    fn session_record_context_export_preflight_requires_live_usable_source() {
24114        let mut source = record("claude", "record-context-export-preflight");
24115        source.state = ManagedSessionState::Live;
24116        assert!(session_record_context_export_source_is_usable(
24117            &source,
24118            &SessionStatus::Running,
24119            false,
24120        ));
24121        assert!(session_record_context_export_source_is_usable(
24122            &source,
24123            &SessionStatus::Exited { exit_code: Some(0) },
24124            false,
24125        ));
24126
24127        source.state = ManagedSessionState::Dormant;
24128        assert!(!session_record_context_export_source_is_usable(
24129            &source,
24130            &SessionStatus::Running,
24131            false,
24132        ));
24133        // The reactive auto-export-at-exit path (allow_clean_detachment) is
24134        // the only caller that may treat a Dormant, cleanly-exited record as
24135        // still usable — the on-demand path above never does.
24136        assert!(session_record_context_export_source_is_usable(
24137            &source,
24138            &SessionStatus::Exited { exit_code: Some(0) },
24139            true,
24140        ));
24141        assert!(!session_record_context_export_source_is_usable(
24142            &source,
24143            &SessionStatus::Exited { exit_code: Some(0) },
24144            false,
24145        ));
24146        source.state = ManagedSessionState::Live;
24147        assert!(!session_record_context_export_source_is_usable(
24148            &source,
24149            &SessionStatus::Starting,
24150            false,
24151        ));
24152    }
24153
24154    #[test]
24155    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24156        let catalog = active_registry().unwrap();
24157        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24158        let workspace = WorkspaceConfig::new(
24159            WorkspaceId::new("primary").unwrap(),
24160            std::env::current_dir().unwrap(),
24161        )
24162        .unwrap();
24163        let shared = NodeShared::new(
24164            handle,
24165            "fixture-token".to_owned(),
24166            NodeId::new("node-context-export-conflict").unwrap(),
24167            vec![workspace],
24168            vec![agent("claude")],
24169        );
24170        let history = HistorySessionRecord {
24171            session_id: "provider-exact".to_owned(),
24172            title: None,
24173            cwd: None,
24174            model: None,
24175            message_count: 1,
24176            completed_turn_count: None,
24177            total_tokens: 0,
24178            messages: vec![gate4agent_types::HistoryMessageRecord {
24179                role: gate4agent_types::HistoryMessageRole::User,
24180                text: "bounded context".to_owned(),
24181            }],
24182        };
24183        let pack = NodeContextPack::export(
24184            ContextPackLineageReceipt {
24185                source_node_id: shared.node_id.clone(),
24186                source_session: terminal_address(1),
24187                source_provider: agent("claude"),
24188            },
24189            &history,
24190        )
24191        .unwrap();
24192        let context_id = pack.receipt().id.clone();
24193        let session = terminal_address(1);
24194        let identity = ProviderSessionIdentity {
24195            key: ProviderSessionKey::SessionId,
24196            id: history.session_id.clone(),
24197            transcript_path: None,
24198        };
24199        let mut expected = record("claude", "record-context-export-final-conflict");
24200        expected.state = ManagedSessionState::Live;
24201        expected.provider_session = Some(identity.clone());
24202        expected.active_session = Some(session.clone());
24203        let mut changed = expected.clone();
24204        changed.updated_at_unix_ms += 1;
24205        shared.insert_record(changed).unwrap();
24206
24207        let error = shared
24208            .commit_context_pack_for_session_record(
24209                &expected,
24210                Some(&identity),
24211                &session,
24212                "candidate-exact",
24213                &history,
24214                pack,
24215                false,
24216            )
24217            .unwrap_err();
24218
24219        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24220        assert!(shared
24221            .context_catalog
24222            .read()
24223            .unwrap_or_else(|poisoned| poisoned.into_inner())
24224            .get(&context_id)
24225            .is_none());
24226    }
24227
24228    #[tokio::test]
24229    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24230        let root = temporary_workspace_root("session-task-binding");
24231        std::fs::create_dir_all(&root).unwrap();
24232        let state_path = root.join("node-state.json");
24233        let catalog = active_registry().unwrap();
24234        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24235        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24236        let node_id = NodeId::new("node-1").unwrap();
24237        let mut shared = NodeShared::new(
24238            handle,
24239            "fixture-token".to_owned(),
24240            node_id.clone(),
24241            vec![workspace],
24242            vec![agent("claude")],
24243        );
24244        shared.state_path = Some(state_path.clone());
24245        let record = record("claude", "sr-task");
24246        let record_id = record.record_id.clone();
24247        shared.insert_record(record).unwrap();
24248        shared.persist_state().unwrap();
24249        shared
24250            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24251            .unwrap();
24252
24253        let minted = process_request_inner(
24254            &shared,
24255            77,
24256            ClientRole::Operator,
24257            NodeRequest::SetSessionTask {
24258                record_id: record_id.clone(),
24259                expected_revision: 0,
24260                target: SessionTaskTargetV1::New,
24261            },
24262        )
24263        .await
24264        .unwrap();
24265        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24266            panic!("session task mutation returned another response");
24267        };
24268        let minted_binding = minted.task_binding.clone().unwrap();
24269        let minted_task_id = minted_binding.task_id.clone().unwrap();
24270        assert_eq!(minted_binding.revision, 1);
24271        assert!(minted_task_id.as_str().starts_with("task-"));
24272        assert_eq!(minted_task_id.as_str().len(), 29);
24273
24274        let event_count = shared.current_sequence();
24275        let repeated = process_request_inner(
24276            &shared,
24277            77,
24278            ClientRole::Operator,
24279            NodeRequest::SetSessionTask {
24280                record_id: record_id.clone(),
24281                expected_revision: 1,
24282                target: SessionTaskTargetV1::Existing {
24283                    task_id: minted_task_id.clone(),
24284                },
24285            },
24286        )
24287        .await
24288        .unwrap();
24289        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24290        assert_eq!(shared.current_sequence(), event_count);
24291
24292        let stale = process_request_inner(
24293            &shared,
24294            77,
24295            ClientRole::Operator,
24296            NodeRequest::SetSessionTask {
24297                record_id: record_id.clone(),
24298                expected_revision: 0,
24299                target: SessionTaskTargetV1::Existing {
24300                    task_id: minted_task_id,
24301                },
24302            },
24303        )
24304        .await
24305        .unwrap_err();
24306        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24307
24308        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24309        let assigned = process_request_inner(
24310            &shared,
24311            77,
24312            ClientRole::Operator,
24313            NodeRequest::SetSessionTask {
24314                record_id: record_id.clone(),
24315                expected_revision: 1,
24316                target: SessionTaskTargetV1::Existing {
24317                    task_id: assigned_id.clone(),
24318                },
24319            },
24320        )
24321        .await
24322        .unwrap();
24323        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24324            panic!("session task assignment returned another response");
24325        };
24326        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24327        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24328
24329        let cleared = process_request_inner(
24330            &shared,
24331            77,
24332            ClientRole::Operator,
24333            NodeRequest::SetSessionTask {
24334                record_id: record_id.clone(),
24335                expected_revision: 2,
24336                target: SessionTaskTargetV1::Clear,
24337            },
24338        )
24339        .await
24340        .unwrap();
24341        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24342            panic!("session task clear returned another response");
24343        };
24344        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24345        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24346        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24347        assert_eq!(
24348            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24349            cleared.updated_at_unix_ms,
24350        );
24351
24352        let persisted = std::fs::read(&state_path).unwrap();
24353        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24354        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24355        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24356        std::fs::remove_dir_all(root).unwrap();
24357    }
24358
24359    #[test]
24360    fn session_task_capability_strips_snapshots_events_and_replies() {
24361        let mut bound = record("claude", "sr-task-projection");
24362        bound.updated_at_unix_ms = 2;
24363        bound.task_binding = Some(SessionTaskBindingV1 {
24364            revision: 1,
24365            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24366            changed_at_unix_ms: 2,
24367        });
24368        let request = NodeRequest::SetSessionTask {
24369            record_id: bound.record_id.clone(),
24370            expected_revision: 1,
24371            target: SessionTaskTargetV1::Clear,
24372        };
24373        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24374            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24375        }));
24376        assert!(!request_uses_unnegotiated_capability(
24377            &request,
24378            &baseline_capabilities().unwrap(),
24379        ));
24380        assert!(request_uses_unnegotiated_capability(&request, &[]));
24381
24382        let mut snapshot = terminal_test_shared().snapshot();
24383        snapshot.session_records.push(bound.clone());
24384        let mut reply = ResponseEnvelope {
24385            request_id: 1,
24386            result: Ok(NodeResponse::Resync {
24387                event_sequence: 1,
24388                oldest_available_sequence: 1,
24389                snapshot,
24390                events: vec![NodeEventEnvelope {
24391                    sequence: 1,
24392                    event: NodeEvent::SessionRecordUpserted {
24393                        record: bound.clone(),
24394                    },
24395                }],
24396            }),
24397        };
24398        project_response_without_session_task_binding(&mut reply);
24399        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24400            panic!("task projection changed the response kind");
24401        };
24402        assert!(snapshot.session_records[0].task_binding.is_none());
24403        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24404            panic!("task projection changed the event kind");
24405        };
24406        assert!(record.task_binding.is_none());
24407
24408        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24409            sequence: 2,
24410            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24411        });
24412        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24413            panic!("task event projection changed the event kind");
24414        };
24415        assert!(record.task_binding.is_none());
24416
24417        let mut direct = ResponseEnvelope {
24418            request_id: 2,
24419            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24420        };
24421        project_response_without_session_task_binding(&mut direct);
24422        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24423            panic!("task reply projection changed the response kind");
24424        };
24425        assert!(record.task_binding.is_none());
24426    }
24427
24428    #[test]
24429    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24430        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24431        let workspace = WorkspaceConfig::new(
24432            WorkspaceId::new("primary").unwrap(),
24433            std::env::current_dir().unwrap(),
24434        )
24435        .unwrap();
24436        let config = NodeServerConfig::new(
24437            DEFAULT_NODE_ENDPOINT,
24438            "fixture-token",
24439            NodeId::new("node-1").unwrap(),
24440            [workspace],
24441        )
24442        .unwrap();
24443        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24444    }
24445
24446    #[cfg(all(feature = "fixture", windows))]
24447    #[test]
24448    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24449        let root = temporary_workspace_root("provider-bundle-argv-hold");
24450        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24451        std::fs::create_dir_all(&root).unwrap();
24452        std::fs::create_dir_all(&outside).unwrap();
24453        let proof_path = root.join("provider.proof");
24454        let release_signal = root.join("release.signal");
24455        let config = NodeServerConfig::new(
24456            format!(
24457                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24458                std::process::id(),
24459            ),
24460            "fixture-token",
24461            NodeId::new("provider-bundle-hold-node").unwrap(),
24462            [WorkspaceConfig::new(
24463                WorkspaceId::new("primary").unwrap(),
24464                std::env::current_dir().unwrap(),
24465            )
24466            .unwrap()],
24467        )
24468        .unwrap()
24469        .with_spawn_profiles(SpawnProfileRegistry::new([
24470            crate::protocol::SpawnProfileDefaults {
24471                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24472                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24473                    .unwrap(),
24474                provider: agent("kimi"),
24475                mode: SessionMode::Pty,
24476                terminal_size: gate4agent_types::TerminalSize {
24477                    rows: 24,
24478                    columns: 80,
24479                },
24480                prompt: None,
24481                bundle_id: None,
24482                context_id: None,
24483                environment_profile_id: None,
24484            },
24485        ])
24486        .unwrap());
24487        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24488            config,
24489            agent("kimi"),
24490            proof_path.clone(),
24491            release_signal.clone(),
24492        )
24493        .unwrap();
24494        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24495
24496        let spec = NodeServer::provider_bundle_argv_fixture_spec(
24497            agent("kimi"),
24498            proof_path.clone(),
24499            Some(release_signal.clone()),
24500        )
24501        .unwrap();
24502        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24503        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24504        let release_wait = wrapper
24505            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24506            .unwrap();
24507        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24508        assert!(proof_write < release_wait);
24509        assert!(release_wait < fixture_ready);
24510        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24511        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24512        assert_eq!(
24513            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24514            &[
24515                proof_path.to_string_lossy().into_owned(),
24516                release_signal.to_string_lossy().into_owned(),
24517            ],
24518        );
24519
24520        let error = NodeServer::provider_bundle_argv_fixture_spec(
24521            agent("kimi"),
24522            proof_path,
24523            Some(outside.join("release.signal")),
24524        )
24525        .unwrap_err();
24526        assert!(matches!(
24527            error,
24528            NodeServerError::Registry(message)
24529                if message == "provider bundle argv release path must share the proof parent"
24530        ));
24531        drop(server);
24532        std::fs::remove_dir_all(root).unwrap();
24533        std::fs::remove_dir_all(outside).unwrap();
24534    }
24535
24536    #[cfg(feature = "fixture")]
24537    #[test]
24538    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24539        let root = temporary_workspace_root("context-fixture-registry");
24540        std::fs::create_dir_all(&root).unwrap();
24541        let proof_path = root.join("context.proof");
24542        let config = NodeServerConfig::new(
24543            format!(
24544                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24545                std::process::id(),
24546            ),
24547            "fixture-token",
24548            NodeId::new("context-fixture-node").unwrap(),
24549            [WorkspaceConfig::new(
24550                WorkspaceId::new("primary").unwrap(),
24551                std::env::current_dir().unwrap(),
24552            )
24553            .unwrap()],
24554        )
24555        .unwrap();
24556        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24557        let mut enabled = server
24558            .shared
24559            .enabled_providers
24560            .iter()
24561            .map(AgentId::as_str)
24562            .collect::<Vec<_>>();
24563        enabled.sort_unstable();
24564        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24565        for provider in &server.shared.enabled_providers {
24566            assert!(server
24567                .shared
24568                .provider_adapter_contracts
24569                .iter()
24570                .any(|contract| {
24571                    &contract.provider == provider
24572                        && contract.family == AdapterFamily::History
24573                }));
24574        }
24575        drop(server);
24576        std::fs::remove_dir_all(root).unwrap();
24577    }
24578
24579    #[cfg(feature = "fixture")]
24580    #[test]
24581    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24582        let root = temporary_workspace_root("monitoring-context-fixture");
24583        std::fs::create_dir_all(&root).unwrap();
24584        let proof_path = root.join("context.proof");
24585        let standard = NodeServer::context_pack_fixture_catalog(
24586            proof_path.clone(),
24587            None,
24588            false,
24589        ).unwrap();
24590        let monitoring = NodeServer::context_pack_fixture_catalog(
24591            proof_path.clone(),
24592            None,
24593            true,
24594        ).unwrap();
24595        let claude = AgentId::new("claude").unwrap();
24596        let standard_claude = standard.get(&claude).unwrap();
24597        let monitoring_claude = monitoring.get(&claude).unwrap();
24598        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
24599        // standard nor the monitoring fixture declares a hook adapter
24600        // anymore.
24601        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24602        assert_eq!(
24603            monitoring_claude.capabilities.adapters.history,
24604            standard_claude.capabilities.adapters.history,
24605        );
24606        assert_ne!(monitoring_claude.launch, standard_claude.launch);
24607        let codex = AgentId::new("codex").unwrap();
24608        let standard_codex = standard.get(&codex).unwrap();
24609        let monitoring_codex = monitoring.get(&codex).unwrap();
24610        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24611        assert_eq!(
24612            monitoring_codex.capabilities.adapters.history,
24613            standard_codex.capabilities.adapters.history,
24614        );
24615        assert_ne!(monitoring_codex.launch, standard_codex.launch);
24616        let codex_validation = monitoring_codex.launch.fixed_args.iter()
24617            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24618            .unwrap();
24619        for required in [
24620            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24621            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24622            "@('role', 'text')",
24623            "g4a-private-provider-session-canary",
24624            "private-provider-login-identity",
24625            "$rawProof",
24626            "$env:GATE4AGENT_HOOK_URL",
24627            "g4a-private-provider-session-canary",
24628        ] {
24629            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24630        }
24631        for provider in ["grok", "kimi"] {
24632            let provider = AgentId::new(provider).unwrap();
24633            assert_eq!(monitoring.get(&provider), standard.get(&provider));
24634        }
24635
24636        let config = NodeServerConfig::new(
24637            format!(
24638                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24639                std::process::id(),
24640            ),
24641            "fixture-token",
24642            NodeId::new("monitoring-context-fixture-node").unwrap(),
24643            [WorkspaceConfig::new(
24644                WorkspaceId::new("primary").unwrap(),
24645                std::env::current_dir().unwrap(),
24646            )
24647            .unwrap()],
24648        )
24649        .unwrap();
24650        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24651        assert!(server.shared.fixture_semantic_hook_policy);
24652        drop(server);
24653        std::fs::remove_dir_all(root).unwrap();
24654    }
24655
24656    #[cfg(feature = "fixture")]
24657    #[test]
24658    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24659        let root = temporary_workspace_root("kimi-context-only-fixture");
24660        std::fs::create_dir_all(&root).unwrap();
24661        let proof_path = root.join("context.proof");
24662        let standard = NodeServer::context_pack_fixture_catalog(
24663            proof_path.clone(),
24664            None,
24665            false,
24666        ).unwrap();
24667        let kimi = AgentId::new("kimi").unwrap();
24668        let context_only = NodeServer::context_pack_fixture_catalog(
24669            proof_path,
24670            Some(&kimi),
24671            false,
24672        )
24673        .unwrap();
24674
24675        let codex = AgentId::new("codex").unwrap();
24676        assert_eq!(
24677            standard.get(&codex).unwrap().launch.fixed_args,
24678            context_only.get(&codex).unwrap().launch.fixed_args,
24679        );
24680        let script = context_only
24681            .get(&kimi)
24682            .unwrap()
24683            .launch
24684            .fixed_args
24685            .iter()
24686            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24687            .expect("Kimi context-only validation script is missing");
24688        for required in [
24689            "$bundleArgs.Count -ne 0",
24690            "GATE4AGENT_CONTEXT_ROOT",
24691            "context-pack.json",
24692            "$entries.Count -ne 1",
24693            "g4a-context-pack-v1",
24694            "$document.PSObject.Properties['cwd']",
24695            "'claude', 'codex', 'grok', 'kimi'",
24696            "$hasUser",
24697            "$hasAssistant",
24698            "SHA256",
24699            "context-only",
24700        ] {
24701            assert!(script.contains(required), "missing validation: {required}");
24702        }
24703        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24704            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24705        }
24706        assert!(!standard
24707            .get(&kimi)
24708            .unwrap()
24709            .launch
24710            .fixed_args
24711            .iter()
24712            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24713
24714        std::fs::remove_dir_all(root).unwrap();
24715    }
24716
24717    #[cfg(all(feature = "fixture", windows))]
24718    #[tokio::test]
24719    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24720        let root = temporary_workspace_root("kimi-context-only-spawn");
24721        let workspace_root = root.join("workspace");
24722        std::fs::create_dir_all(&workspace_root).unwrap();
24723        let materialization_root = root.join("private-materializations");
24724        let proof_path = root.join("context.proof");
24725        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24726        let workspace_id = WorkspaceId::new("primary").unwrap();
24727        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24728        let endpoint = format!(
24729            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24730            std::process::id(),
24731            unix_time_ms(),
24732        );
24733        let spawn_profiles = SpawnProfileRegistry::new([
24734            crate::protocol::SpawnProfileDefaults {
24735                profile_id: profile_id.clone(),
24736                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24737                provider: agent("kimi"),
24738                mode: SessionMode::Pty,
24739                terminal_size: gate4agent_types::TerminalSize {
24740                    rows: 30,
24741                    columns: 120,
24742                },
24743                prompt: None,
24744                bundle_id: None,
24745                context_id: None,
24746                environment_profile_id: None,
24747            },
24748        ])
24749        .unwrap();
24750        let mut config = NodeServerConfig::new(
24751            &endpoint,
24752            "fixture-token",
24753            node_id.clone(),
24754            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24755        )
24756        .unwrap()
24757        .with_spawn_profiles(spawn_profiles)
24758        .with_session_environment_materialization(
24759            materialization_root,
24760            Arc::new(FixtureSecretResolver {
24761                deny: Arc::new(AtomicBool::new(false)),
24762            }),
24763        )
24764        .unwrap();
24765        config.fixture_raw_pty_runtime = true;
24766        let server = NodeServer::new_context_only_proof_fixture(
24767            config,
24768            agent("kimi"),
24769            proof_path.clone(),
24770        )
24771        .unwrap();
24772        let shared = Arc::clone(&server.shared);
24773        let shutdown = server.shutdown_handle();
24774
24775        let history = HistorySessionRecord {
24776            session_id: "context-only-source-history".to_owned(),
24777            title: None,
24778            cwd: None,
24779            model: None,
24780            message_count: 2,
24781            completed_turn_count: None,
24782            total_tokens: 0,
24783            messages: vec![
24784                gate4agent_types::HistoryMessageRecord {
24785                    role: gate4agent_types::HistoryMessageRole::User,
24786                    text: "continue the bounded parent run through native C2".to_owned(),
24787                },
24788                gate4agent_types::HistoryMessageRecord {
24789                    role: gate4agent_types::HistoryMessageRole::Assistant,
24790                    text: "the exact parent context is ready for the child run".to_owned(),
24791                },
24792            ],
24793        };
24794        let pack = NodeContextPack::export(
24795            ContextPackLineageReceipt {
24796                source_node_id: node_id.clone(),
24797                source_session: SessionAddress {
24798                    workspace_id: workspace_id.clone(),
24799                    session: SessionKey {
24800                        instance_id: AgentInstanceId(71),
24801                        generation: SessionGeneration(1),
24802                    },
24803                },
24804                source_provider: agent("codex"),
24805            },
24806            &history,
24807        )
24808        .unwrap();
24809        let expected_context_hash = digest(&SHA256, pack.bytes())
24810            .as_ref()
24811            .iter()
24812            .map(|byte| format!("{byte:02x}"))
24813            .collect::<String>();
24814        let context = shared
24815            .context_catalog
24816            .write()
24817            .unwrap_or_else(|poisoned| poisoned.into_inner())
24818            .insert(pack)
24819            .unwrap();
24820        let spec = SpawnSpec {
24821            target: crate::protocol::SpawnTarget {
24822                node_id,
24823                workspace_id: workspace_id.clone(),
24824                worktree_id: None,
24825            },
24826            profile_id,
24827            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24828                "target-kimi-r1",
24829            ).unwrap(),
24830            overrides: crate::protocol::SpawnOverrides {
24831                context_id: crate::protocol::SpawnOverride::Set {
24832                    value: context.id.clone(),
24833                },
24834                ..crate::protocol::SpawnOverrides::default()
24835            },
24836            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24837            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24838            required_capabilities: SpawnRequiredCapabilities::default(),
24839        };
24840        let resolved = match shared.resolve_spawn_spec(&spec) {
24841            Ok(resolved) => resolved,
24842            Err(error) => panic!(
24843                "Kimi context-only resolve rejected: exact_code={:?} message={}",
24844                error.code,
24845                error.message,
24846            ),
24847        };
24848        let resolved_context = match shared.resolve_context(&resolved) {
24849            Ok(Some(resolved_context)) => resolved_context,
24850            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24851            Err(error) => panic!(
24852                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24853                error.code,
24854                error.message,
24855            ),
24856        };
24857        assert_eq!(resolved_context, context);
24858        let runtime_policy = match shared
24859            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24860            .await
24861        {
24862            Ok(runtime_policy) => runtime_policy,
24863            Err(error) => panic!(
24864                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24865                error.code,
24866                error.message,
24867            ),
24868        };
24869        assert!(runtime_policy.raw_pty_lifecycle);
24870
24871        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24872        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24873            agent("kimi"),
24874            TransportKind::Pty,
24875            vec![EnvMutation {
24876                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24877                value: Some(OsString::from("must-remain-rejected")),
24878            }],
24879        )
24880        .unwrap();
24881        assert_eq!(
24882            launch_control
24883                .install_native_launch_environment_overlay(
24884                    AgentInstanceId(72),
24885                    generic_overlay,
24886                )
24887                .unwrap_err(),
24888            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24889        );
24890
24891        let diagnostic_address = SessionAddress {
24892            workspace_id: workspace_id.clone(),
24893            session: SessionKey {
24894                instance_id: AgentInstanceId(73),
24895                generation: SessionGeneration(1),
24896            },
24897        };
24898        let (diagnostic_overlay, diagnostic_materialization) = match shared
24899            .prepare_session_materialization(
24900                &diagnostic_address,
24901                &agent("kimi"),
24902                SessionMode::Pty,
24903                None,
24904                None,
24905                Some(&resolved_context),
24906                None,
24907            )
24908        {
24909            Ok(Some(prepared)) => prepared,
24910            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24911            Err(error) => panic!(
24912                "Kimi context-only materialization rejected: exact_code={:?} message={}",
24913                error.code,
24914                error.message,
24915            ),
24916        };
24917        let diagnostic_overlay = diagnostic_overlay
24918            .expect("Kimi context-only materialization omitted its environment overlay");
24919        assert!(matches!(
24920            &diagnostic_overlay,
24921            PreparedNativeLaunchOverlay::Environment(_),
24922        ));
24923        match shared.install_prepared_launch_overlay(
24924            diagnostic_address.session.instance_id,
24925            diagnostic_overlay,
24926        ) {
24927            Ok(None) => {}
24928            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24929            Err(error) => panic!(
24930                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24931                error.code,
24932                error.message,
24933            ),
24934        }
24935        assert!(launch_control.clear_native_instance_launch_overlay(
24936            diagnostic_address.session.instance_id,
24937        ));
24938        drop(diagnostic_materialization);
24939        assert!(shared
24940            .materializations
24941            .lock()
24942            .unwrap_or_else(|poisoned| poisoned.into_inner())
24943            .is_empty());
24944
24945        let node_task = tokio::spawn(server.run());
24946        let ready_client = timeout(Duration::from_secs(5), async {
24947            loop {
24948                match gate4agent_node_wire::LocalNodeClient::connect(
24949                    &endpoint,
24950                    &shared.node_id,
24951                    ClientRole::Observer,
24952                    "fixture-token",
24953                )
24954                .await
24955                {
24956                    Ok(client) => break client,
24957                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24958                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24959                }
24960            }
24961        })
24962        .await
24963        .expect("Kimi context-only Node did not become ready");
24964        assert_eq!(
24965            ready_client
24966                .hello()
24967                .snapshot
24968                .provider_runtime_statuses
24969                .iter()
24970                .find(|status| status.provider().as_str() == "kimi")
24971                .expect("Kimi runtime status is missing")
24972                .mode(),
24973            crate::protocol::ProviderRuntimeMode::RawPassthrough,
24974        );
24975        drop(ready_client);
24976
24977        let receipt = match shared.spawn_from_spec(spec).await {
24978            Ok(receipt) => receipt,
24979            Err(error) => panic!(
24980                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24981                error.code,
24982                error.message,
24983            ),
24984        };
24985        assert_eq!(receipt.provider, agent("kimi"));
24986        assert_eq!(receipt.context.as_ref(), Some(&context));
24987        assert!(receipt.context_binding_is_valid());
24988        timeout(Duration::from_secs(5), async {
24989            while !proof_path.is_file() {
24990                sleep(Duration::from_millis(10)).await;
24991            }
24992        })
24993        .await
24994        .expect("Kimi context-only fixture did not write its proof");
24995
24996        let proof = std::fs::read_to_string(&proof_path).unwrap();
24997        let lines = proof.lines().collect::<Vec<_>>();
24998        assert_eq!(lines.len(), 7);
24999        assert_eq!(lines[0], "context-only");
25000        assert!(Path::new(lines[1]).is_absolute());
25001        assert_ne!(
25002            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25003            std::fs::canonicalize(&workspace_root).unwrap(),
25004        );
25005        assert_eq!(
25006            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25007            std::fs::canonicalize(&workspace_root).unwrap(),
25008        );
25009        assert_eq!(lines[3], expected_context_hash);
25010        assert_eq!(lines[4], "g4a-context-pack-v1");
25011        assert_eq!(lines[5], "codex");
25012        assert_eq!(lines[6], "2");
25013        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25014            session.instance_id == receipt.session.session.instance_id
25015                && session.agent_id.as_str() == "kimi"
25016        }));
25017
25018        shutdown.request_shutdown().await.unwrap();
25019        timeout(Duration::from_secs(5), node_task)
25020            .await
25021            .unwrap()
25022            .unwrap()
25023            .unwrap();
25024        drop(shutdown);
25025        drop(shared);
25026        std::fs::remove_dir_all(root).unwrap();
25027    }
25028
25029    #[cfg(feature = "fixture")]
25030    #[test]
25031    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25032        let root = temporary_workspace_root("clean-exit-constructor");
25033        std::fs::create_dir_all(&root).unwrap();
25034        let config = construction_test_config("clean-exit-constructor")
25035            .with_state_path(root.join("node-state.json"))
25036            .unwrap();
25037        let server = NodeServer::new_clean_exit_fixture(
25038            config,
25039            root.clone(),
25040            root.join("started.marker"),
25041            root.join("release.signal"),
25042        )
25043        .unwrap();
25044        assert_eq!(
25045            server
25046                .shared
25047                .enabled_providers
25048                .iter()
25049                .map(AgentId::as_str)
25050                .collect::<Vec<_>>(),
25051            ["claude"],
25052        );
25053        drop(server);
25054        std::fs::remove_dir_all(root).unwrap();
25055    }
25056
25057    #[test]
25058    fn production_registry_provider_contract_manifest_is_exact() {
25059        let registry = active_registry().unwrap();
25060        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25061        assert_eq!(
25062            providers
25063                .iter()
25064                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25065                .collect::<Vec<_>>(),
25066            vec![
25067                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25068                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25069                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25070                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25071            ]
25072        );
25073        assert_eq!(
25074            adapters
25075                .iter()
25076                .map(|contract| (
25077                    contract.provider.as_str(),
25078                    contract.family,
25079                    contract.adapter_id.as_str(),
25080                    contract.revision.as_str(),
25081                ))
25082                .collect::<Vec<_>>(),
25083            vec![
25084                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25085                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25086                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25087                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25088                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25089                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25090                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25091                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25092                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25093                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25094                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25095                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25096                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25097                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25098                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25099                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25100                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25101                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25102                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25103                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25104                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25105                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25106                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25107            ]
25108        );
25109    }
25110
25111    fn construction_test_config(name: &str) -> NodeServerConfig {
25112        let workspace = WorkspaceConfig::new(
25113            WorkspaceId::new("primary").unwrap(),
25114            std::env::current_dir().unwrap(),
25115        )
25116        .unwrap();
25117        NodeServerConfig::new(
25118            format!(r"\\.\pipe\gate4agent-{name}"),
25119            "fixture-token",
25120            NodeId::new(name).unwrap(),
25121            [workspace],
25122        )
25123        .unwrap()
25124    }
25125
25126    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25127        let adapter = AdapterBinding::new(
25128            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25129            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25130            gate4agent_types::AdapterVerification::SyntheticFixture,
25131        )
25132        .unwrap();
25133        let agent_ids = ["claude", "codex", "kimi"]
25134            .into_iter()
25135            .map(|agent_id| AgentId::new(agent_id).unwrap())
25136            .collect::<Vec<_>>();
25137        let adapters = gate4agent_catalog::AdapterRegistry::new(
25138            [
25139                AdapterFamily::PtySemantic,
25140                AdapterFamily::Pipe,
25141                AdapterFamily::OneShot,
25142                AdapterFamily::Acp,
25143                AdapterFamily::Hook,
25144                AdapterFamily::ManagedHook,
25145                AdapterFamily::History,
25146                AdapterFamily::Resume,
25147                AdapterFamily::SessionOptions,
25148                AdapterFamily::CapabilityProbe,
25149            ]
25150            .into_iter()
25151            .map(|family| gate4agent_catalog::AdapterDescriptor {
25152                family,
25153                binding: adapter.clone(),
25154                agents: agent_ids.clone(),
25155            }),
25156        )
25157        .unwrap();
25158        let specs = agent_ids
25159            .iter()
25160            .map(|agent_id| {
25161                let agent_id = agent_id.as_str();
25162                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25163                spec.revision =
25164                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25165                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25166                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25167                    adapter: adapter.clone(),
25168                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25169                    launch_override: None,
25170                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25171                });
25172                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25173                    adapter: adapter.clone(),
25174                    launch_override: None,
25175                });
25176                spec.capabilities.adapters.hook = Some(adapter.clone());
25177                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25178                spec.capabilities.adapters.one_shot = Some(adapter.clone());
25179                spec.capabilities.adapters.history = Some(adapter.clone());
25180                spec.capabilities.adapters.resume = Some(adapter.clone());
25181                spec.capabilities.adapters.session_options = Some(adapter.clone());
25182                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25183                spec
25184            })
25185            .collect::<Vec<_>>();
25186        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25187    }
25188
25189    /// The real fleet's manifest is accepted by a real server construction.
25190    ///
25191    /// The count is asserted against `active_registry` itself rather than a
25192    /// literal, so this stays a test that construction succeeds for whatever
25193    /// the fleet is -- which is the property worth guarding -- instead of a
25194    /// second place the fleet size is written down and has to be kept in
25195    /// step with the first.
25196    #[test]
25197    fn node_server_construction_accepts_the_production_manifest() {
25198        let registry = active_registry().unwrap();
25199        let (providers, _) = provider_contract_manifest(&registry).unwrap();
25200        assert_eq!(providers.len(), registry.iter().count());
25201        assert!(!providers.is_empty());
25202        let server = NodeServer::new_with_registry(
25203            construction_test_config("manifest-production-bound"),
25204            registry,
25205        )
25206        .unwrap();
25207        drop(server);
25208    }
25209
25210    #[test]
25211    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25212        let registry = maximum_size_provider_manifest_registry();
25213        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25214        assert_eq!(providers.len(), 3);
25215        assert_eq!(adapters.len(), 30);
25216        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25217        let error = match NodeServer::new_with_registry(
25218            construction_test_config("manifest-handshake-overflow"),
25219            registry,
25220        ) {
25221            Ok(_) => panic!("oversized negotiated manifest was accepted"),
25222            Err(error) => error,
25223        };
25224        assert!(matches!(
25225            error,
25226            NodeServerError::ProviderContractManifest(message)
25227                if message.contains("authentication binding length")
25228                    && message.contains("8192-byte limit")
25229        ));
25230    }
25231
25232    #[test]
25233    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25234        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25235        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25236        let registry = AgentRegistry::new([spec]).unwrap();
25237        assert!(matches!(
25238            provider_contract_manifest(&registry),
25239            Err(NodeServerError::ProviderContractManifest(message))
25240                if message.contains("exceeds the 128-byte limit")
25241        ));
25242    }
25243
25244    #[test]
25245    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25246        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25247        spec.revision = "invalid revision".to_owned();
25248        let registry = AgentRegistry::new([spec]).unwrap();
25249        assert!(matches!(
25250            provider_contract_manifest(&registry),
25251            Err(NodeServerError::ProviderContractManifest(message))
25252                if message.contains("bounded lowercase ASCII")
25253        ));
25254    }
25255
25256    #[test]
25257    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25258        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25259        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25260        let conflicting_binding = builtin_registry()
25261            .get_by_id("codex")
25262            .unwrap()
25263            .capabilities
25264            .adapters
25265            .one_shot
25266            .clone()
25267            .unwrap();
25268        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25269        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25270        pipe.adapter = transport_binding;
25271        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25272        let registry = AgentRegistry::new([spec]).unwrap();
25273        assert!(matches!(
25274            provider_contract_manifest(&registry),
25275            Err(NodeServerError::ProviderContractManifest(message))
25276                if message.contains("conflicting OneShot adapter bindings")
25277        ));
25278    }
25279
25280    #[test]
25281    fn public_node_failures_expose_only_fixed_categories() {
25282        let provider = failure(
25283            NodeFailureCode::BackendOperationFailed,
25284            r"provider bearer-secret failed at C:\private\session.jsonl",
25285        );
25286        assert_eq!(provider.message, "backend-operation-failed");
25287        assert!(!provider.message.contains("bearer-secret"));
25288        assert!(!provider.message.contains(r"C:\private"));
25289        let persistence = persistence_failure(io::Error::new(
25290            io::ErrorKind::Other,
25291            r"state write failed at C:\private\state-v1.json",
25292        ));
25293        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25294        assert!(!persistence.message.contains(r"C:\private"));
25295
25296        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25297            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25298            assert_eq!(
25299                generic.to_string(),
25300                "node durable state failed: durable-state-load-failed",
25301            );
25302        }
25303    }
25304
25305    #[test]
25306    fn terminal_byte_sequences_are_bounded_before_dispatch() {
25307        let sequence = b"\x1b[1;5D".to_vec();
25308        assert_eq!(
25309            terminal_bytes_action(sequence.clone()).unwrap(),
25310            InputAction::TerminalBytes(sequence),
25311        );
25312        assert_eq!(
25313            terminal_bytes_action(Vec::new()).unwrap_err().code,
25314            NodeFailureCode::InvalidRequest,
25315        );
25316        assert_eq!(
25317            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25318                .unwrap_err()
25319                .code,
25320            NodeFailureCode::InvalidRequest,
25321        );
25322    }
25323
25324    #[test]
25325    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25326        let root = temporary_workspace_root("exclusive-state-owner");
25327        std::fs::create_dir_all(&root).unwrap();
25328        let workspace = WorkspaceConfig::new(
25329            WorkspaceId::new("primary").unwrap(),
25330            &root,
25331        )
25332        .unwrap();
25333        let state_path = root.join("state-v1.json");
25334        let endpoint = format!(
25335            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25336            std::process::id(),
25337            std::time::SystemTime::now()
25338                .duration_since(std::time::UNIX_EPOCH)
25339                .unwrap()
25340                .as_nanos(),
25341        );
25342        let config = NodeServerConfig::new(
25343            endpoint,
25344            "fixture-token",
25345            NodeId::new("node-1").unwrap(),
25346            [workspace],
25347        )
25348        .unwrap()
25349        .with_state_path(&state_path)
25350        .unwrap();
25351
25352        let first = NodeServer::new(config.clone()).unwrap();
25353        let error = match NodeServer::new(config.clone()) {
25354            Ok(_) => panic!("a second node server acquired the same durable state path"),
25355            Err(error) => error,
25356        };
25357        assert!(matches!(
25358            &error,
25359            NodeServerError::DurableState(error)
25360                if error.kind() == io::ErrorKind::WouldBlock
25361        ));
25362        assert_eq!(
25363            error.to_string(),
25364            "node durable state failed: durable-state-lock-failed",
25365        );
25366        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25367        drop(first);
25368        let second = NodeServer::new(config).unwrap();
25369        drop(second);
25370        std::fs::remove_dir_all(root).unwrap();
25371    }
25372
25373    #[test]
25374    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25375        let catalog = active_registry().unwrap();
25376        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25377        let workspace_id = WorkspaceId::new("primary").unwrap();
25378        let workspace = WorkspaceConfig::new(
25379            workspace_id.clone(),
25380            std::env::current_dir().unwrap(),
25381        )
25382        .unwrap();
25383        let shared = NodeShared::new(
25384            handle,
25385            "fixture-token".to_owned(),
25386            NodeId::new("node-1").unwrap(),
25387            vec![workspace.clone()],
25388            vec![agent("claude"), agent("codex"), agent("kimi")],
25389        );
25390        let instance_id = AgentInstanceId(17);
25391        let generation = SessionGeneration(3);
25392        assert!(shared.address_for(instance_id, generation).is_none());
25393        shared.bind_session(instance_id, workspace_id.clone(), generation);
25394        let address = SessionAddress {
25395            workspace_id,
25396            session: SessionKey { instance_id, generation },
25397        };
25398        assert_eq!(
25399            shared.workspace_root(&address.workspace_id).unwrap(),
25400            workspace.canonical_root,
25401        );
25402        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25403        shared.remove_binding(&address);
25404        assert!(shared.address_for(instance_id, generation).is_none());
25405    }
25406
25407    #[test]
25408    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25409        let address = SessionAddress {
25410            workspace_id: WorkspaceId::new("primary").unwrap(),
25411            session: SessionKey {
25412                instance_id: AgentInstanceId(7),
25413                generation: SessionGeneration(2),
25414            },
25415        };
25416        let record_id = SessionRecordId::new("record-a").unwrap();
25417        let size = gate4agent_types::TerminalSize {
25418            rows: 24,
25419            columns: 80,
25420        };
25421        let address_requests = [
25422            NodeRequest::Resume {
25423                session: address.clone(),
25424                terminal_size: size,
25425                initial_prompt: None,
25426            },
25427            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25428            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25429            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25430            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25431            NodeRequest::TerminalControl {
25432                session: address.clone(),
25433                control: TerminalControl::Enter,
25434            },
25435            NodeRequest::Resize { session: address.clone(), size },
25436            NodeRequest::Interrupt { session: address.clone() },
25437            NodeRequest::Stop { session: address.clone(), force: false },
25438            NodeRequest::Remove { session: address },
25439        ];
25440        for request in &address_requests {
25441            assert!(request_requires_open_provider_ids_with(
25442                request,
25443                |_| Some(agent("third-party-agent")),
25444                |_| Some(agent("claude")),
25445            ));
25446            assert!(!request_requires_open_provider_ids_with(
25447                request,
25448                |_| Some(agent("claude")),
25449                |_| Some(agent("claude")),
25450            ));
25451            assert!(request_requires_open_provider_ids_with(
25452                request,
25453                |_| None,
25454                |_| Some(agent("claude")),
25455            ));
25456        }
25457
25458        let record_requests = [
25459            NodeRequest::RenameSessionRecord {
25460                record_id: record_id.clone(),
25461                display_name: "renamed".to_owned(),
25462            },
25463            NodeRequest::ResumeSessionRecord {
25464                record_id: record_id.clone(),
25465                terminal_size: size,
25466                initial_prompt: None,
25467            },
25468            NodeRequest::ForgetSessionRecord { record_id },
25469        ];
25470        for request in &record_requests {
25471            assert!(request_requires_open_provider_ids_with(
25472                request,
25473                |_| Some(agent("claude")),
25474                |_| Some(agent("grok")),
25475            ));
25476            assert!(!request_requires_open_provider_ids_with(
25477                request,
25478                |_| Some(agent("claude")),
25479                |_| Some(agent("kimi")),
25480            ));
25481            assert!(request_requires_open_provider_ids_with(
25482                request,
25483                |_| Some(agent("claude")),
25484                |_| None,
25485            ));
25486        }
25487
25488        assert!(request_requires_open_provider_ids_with(
25489            &NodeRequest::Spawn {
25490                workspace_id: WorkspaceId::new("primary").unwrap(),
25491                provider: agent("third-party-agent"),
25492                mode: SessionMode::Pty,
25493                terminal_size: size,
25494                initial_prompt: None,
25495            },
25496            |_| None,
25497            |_| None,
25498        ));
25499        assert!(!request_requires_open_provider_ids_with(
25500            &NodeRequest::Snapshot,
25501            |_| None,
25502            |_| None,
25503        ));
25504        assert!(request_requires_open_provider_ids_with(
25505            &NodeRequest::ForgetContextPack {
25506                context_id: SpawnContextId::new("ctx-forget").unwrap(),
25507            },
25508            |_| None,
25509            |_| None,
25510        ));
25511    }
25512
25513    #[test]
25514    fn legacy_projection_filters_open_record_replies_and_event_history() {
25515        let catalog = active_registry().unwrap();
25516        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25517        let workspace = WorkspaceConfig::new(
25518            WorkspaceId::new("primary").unwrap(),
25519            std::env::current_dir().unwrap(),
25520        )
25521        .unwrap();
25522        let shared = NodeShared::new(
25523            handle,
25524            "fixture-token".to_owned(),
25525            NodeId::new("node-1").unwrap(),
25526            vec![workspace],
25527            vec![agent("claude"), agent("grok")],
25528        );
25529        let legacy = record("claude", "legacy-record");
25530        let open = record("grok", "open-record");
25531        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25532            record: legacy.clone(),
25533        });
25534        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25535            record: open.clone(),
25536        });
25537        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25538            record_id: legacy.record_id.clone(),
25539        });
25540        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25541            record_id: open.record_id.clone(),
25542        });
25543        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25544        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25545        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25546        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25547
25548        let mut record_reply = ResponseEnvelope {
25549            request_id: 1,
25550            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25551        };
25552        project_response_legacy_provider_ids(&shared, &mut record_reply);
25553        assert!(matches!(
25554            record_reply.result,
25555            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25556        ));
25557
25558        let mut resync = ResponseEnvelope {
25559            request_id: 2,
25560            result: Ok(NodeResponse::Resync {
25561                event_sequence: open_removed.sequence,
25562                oldest_available_sequence: 1,
25563                snapshot: shared.snapshot(),
25564                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25565            }),
25566        };
25567        project_response_legacy_provider_ids(&shared, &mut resync);
25568        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25569            panic!("legacy projection changed the resync response kind");
25570        };
25571        assert!(snapshot.session_records.iter().all(|item| {
25572            provider_id_is_legacy(&item.provider)
25573        }));
25574        assert_eq!(events.len(), 2);
25575        assert!(events.iter().all(|event| matches!(
25576            &event.event,
25577            NodeEvent::SessionRecordUpserted { record }
25578                if provider_id_is_legacy(&record.provider)
25579        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25580    }
25581
25582    #[test]
25583    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25584        let catalog = active_registry().unwrap();
25585        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25586        let workspace_id = WorkspaceId::new("primary").unwrap();
25587        let workspace = WorkspaceConfig::new(
25588            workspace_id.clone(),
25589            std::env::current_dir().unwrap(),
25590        )
25591        .unwrap();
25592        let shared = NodeShared::new(
25593            handle,
25594            "fixture-token".to_owned(),
25595            NodeId::new("node-1").unwrap(),
25596            vec![workspace],
25597            vec![agent("claude")],
25598        );
25599        for index in 0..CONTROL_SESSIONS_MAX {
25600            shared.bind_session(
25601                AgentInstanceId((index + 1) as u64),
25602                workspace_id.clone(),
25603                SessionGeneration(1),
25604            );
25605        }
25606        let error = shared.ensure_binding_capacity().unwrap_err();
25607        assert_eq!(error.code, NodeFailureCode::BackendBusy);
25608    }
25609
25610    #[tokio::test]
25611    async fn provider_spawn_admission_rejects_before_session_mutation() {
25612        let catalog = active_registry().unwrap();
25613        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25614        let workspace = WorkspaceConfig::new(
25615            WorkspaceId::new("primary").unwrap(),
25616            std::env::current_dir().unwrap(),
25617        )
25618        .unwrap();
25619        let mut shared = NodeShared::new(
25620            handle,
25621            "fixture-token".to_owned(),
25622            NodeId::new("node-provider-admission").unwrap(),
25623            vec![workspace],
25624            vec![agent("claude")],
25625        );
25626        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25627            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25628        ])
25629        .unwrap();
25630        let terminal_size = gate4agent_types::TerminalSize {
25631            rows: 24,
25632            columns: 80,
25633        };
25634
25635        let unavailable = shared
25636            .spawn_session(
25637                WorkspaceId::new("primary").unwrap(),
25638                agent("claude"),
25639                SessionMode::Pty,
25640                terminal_size,
25641                None,
25642            )
25643            .await
25644            .unwrap_err();
25645        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25646        assert_eq!(unavailable.message, "backend-operation-failed");
25647
25648        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25649            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25650                agent("claude"),
25651                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25652            ),
25653        ])
25654        .unwrap();
25655        let semantic = shared
25656            .spawn_session(
25657                WorkspaceId::new("primary").unwrap(),
25658                agent("claude"),
25659                SessionMode::Inline,
25660                terminal_size,
25661                None,
25662            )
25663            .await
25664            .unwrap_err();
25665        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25666        assert_eq!(semantic.message, "unsupported-capability");
25667
25668        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25669        assert!(shared
25670            .session_bindings
25671            .lock()
25672            .unwrap_or_else(|poisoned| poisoned.into_inner())
25673            .is_empty());
25674        assert!(shared
25675            .session_records
25676            .lock()
25677            .unwrap_or_else(|poisoned| poisoned.into_inner())
25678            .records
25679            .is_empty());
25680        assert!(shared.handle.snapshot().sessions.is_empty());
25681    }
25682
25683    #[tokio::test]
25684    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25685        let catalog = active_registry().unwrap();
25686        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25687        let workspace_id = WorkspaceId::new("primary").unwrap();
25688        let workspace = WorkspaceConfig::new(
25689            workspace_id.clone(),
25690            std::env::current_dir().unwrap(),
25691        )
25692        .unwrap();
25693        let shared = NodeShared::new(
25694            handle,
25695            "fixture-token".to_owned(),
25696            NodeId::new("node-raw-spawn-record").unwrap(),
25697            vec![workspace],
25698            vec![agent("claude")],
25699        );
25700        let address = SessionAddress {
25701            workspace_id,
25702            session: SessionKey {
25703                instance_id: AgentInstanceId(71),
25704                generation: SessionGeneration::default(),
25705            },
25706        };
25707
25708        let record_id = shared
25709            .bind_spawn_session(
25710                &address,
25711                agent("claude"),
25712                SessionMode::Pty,
25713                ProviderRuntimePolicy::raw_pty(),
25714                SpawnRecordPolicy::Always,
25715                None,
25716            )
25717            .unwrap()
25718            .unwrap();
25719        shared
25720            .handle
25721            .dispatch(shared.prepare_command(ControlCommand::Register {
25722                instance_id: address.session.instance_id,
25723                agent_id: agent("claude"),
25724                transport: TransportKind::Pty,
25725            }))
25726            .unwrap();
25727        runtime.tick().await;
25728
25729        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25730            session.instance_id == address.session.instance_id
25731                && session.generation == address.session.generation
25732        }));
25733        let record = shared.record(&record_id).unwrap();
25734        assert_eq!(record.display_name, "claude #1");
25735        assert_eq!(record.state, ManagedSessionState::Live);
25736        assert_eq!(record.active_session.as_ref(), Some(&address));
25737        assert!(record.provider_session.is_none());
25738        let binding_record_id = shared
25739            .session_bindings
25740            .lock()
25741            .unwrap_or_else(|poisoned| poisoned.into_inner())
25742            .get(&address.session.instance_id)
25743            .and_then(|binding| binding.record_id.clone());
25744        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25745
25746        let renamed = shared
25747            .rename_session_record(&record_id, "raw review".to_owned())
25748            .unwrap();
25749        assert_eq!(renamed.record_id, record_id);
25750        assert_eq!(renamed.display_name, "raw review");
25751        shared.reconcile_managed_record(
25752            &record_id,
25753            &address,
25754            &ControlEvent {
25755                sequence: 1,
25756                command_id: None,
25757                instance_id: address.session.instance_id,
25758                generation: address.session.generation,
25759                event: ControlEventKind::Running { process_id: None },
25760            },
25761            false,
25762            false,
25763        );
25764        let running = shared.record(&record_id).unwrap();
25765        assert_eq!(running.state, ManagedSessionState::Live);
25766        assert_eq!(running.active_session.as_ref(), Some(&address));
25767        assert_eq!(running.display_name, "raw review");
25768
25769        shared.reconcile_managed_record(
25770            &record_id,
25771            &address,
25772            &ControlEvent {
25773                sequence: 2,
25774                command_id: None,
25775                instance_id: address.session.instance_id,
25776                generation: address.session.generation,
25777                event: ControlEventKind::Exited {
25778                    exit_code: Some(0),
25779                    forced: false,
25780                },
25781            },
25782            false,
25783            false,
25784        );
25785        let detached = shared.record(&record_id).unwrap();
25786        assert_eq!(detached.state, ManagedSessionState::Unavailable);
25787        assert!(detached.active_session.is_none());
25788        assert_eq!(detached.display_name, "raw review");
25789        let renamed_detached = shared
25790            .rename_session_record(&record_id, "raw review detached".to_owned())
25791            .unwrap();
25792        assert_eq!(renamed_detached.record_id, record_id);
25793        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25794        assert!(renamed_detached.active_session.is_none());
25795        assert_eq!(renamed_detached.display_name, "raw review detached");
25796        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25797    }
25798
25799    #[test]
25800    fn legacy_raw_spawn_binding_remains_untracked() {
25801        let catalog = active_registry().unwrap();
25802        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25803        let workspace_id = WorkspaceId::new("primary").unwrap();
25804        let workspace = WorkspaceConfig::new(
25805            workspace_id.clone(),
25806            std::env::current_dir().unwrap(),
25807        )
25808        .unwrap();
25809        let shared = NodeShared::new(
25810            handle,
25811            "fixture-token".to_owned(),
25812            NodeId::new("node-legacy-raw-spawn").unwrap(),
25813            vec![workspace],
25814            vec![agent("claude")],
25815        );
25816        let address = SessionAddress {
25817            workspace_id,
25818            session: SessionKey {
25819                instance_id: AgentInstanceId(72),
25820                generation: SessionGeneration::default(),
25821            },
25822        };
25823
25824        let record_id = shared
25825            .bind_spawn_session(
25826                &address,
25827                agent("claude"),
25828                SessionMode::Pty,
25829                ProviderRuntimePolicy::raw_pty(),
25830                SpawnRecordPolicy::ProviderIdentityOnly,
25831                None,
25832            )
25833            .unwrap();
25834
25835        assert!(record_id.is_none());
25836        let binding_record_id = shared
25837            .session_bindings
25838            .lock()
25839            .unwrap_or_else(|poisoned| poisoned.into_inner())
25840            .get(&address.session.instance_id)
25841            .and_then(|binding| binding.record_id.clone());
25842        assert!(binding_record_id.is_none());
25843        assert!(shared.snapshot().session_records.is_empty());
25844    }
25845
25846    #[tokio::test]
25847    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25848        let catalog = active_registry().unwrap();
25849        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25850        let workspace_id = WorkspaceId::new("primary").unwrap();
25851        let workspace = WorkspaceConfig::new(
25852            workspace_id.clone(),
25853            std::env::current_dir().unwrap(),
25854        )
25855        .unwrap();
25856        let mut shared = NodeShared::new(
25857            handle,
25858            "fixture-token".to_owned(),
25859            NodeId::new("node-raw-semantic-gate").unwrap(),
25860            vec![workspace],
25861            vec![agent("claude")],
25862        );
25863        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25864            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25865        ])
25866        .unwrap();
25867        shared
25868            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25869            .unwrap();
25870        let address = SessionAddress {
25871            workspace_id,
25872            session: SessionKey {
25873                instance_id: AgentInstanceId(72),
25874                generation: SessionGeneration::default(),
25875            },
25876        };
25877        shared.bind_session_with_policy(
25878            &address,
25879            ProviderRuntimePolicy::raw_pty(),
25880            None,
25881        );
25882        shared
25883            .handle
25884            .dispatch(shared.prepare_command(ControlCommand::Register {
25885                instance_id: address.session.instance_id,
25886                agent_id: agent("claude"),
25887                transport: TransportKind::Pty,
25888            }))
25889            .unwrap();
25890        runtime.tick().await;
25891        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25892        let revision_before = shared.handle.snapshot().revision;
25893        let history_sequence_before = shared.current_sequence();
25894        let terminal_size = gate4agent_types::TerminalSize {
25895            rows: 24,
25896            columns: 80,
25897        };
25898
25899        for request in [
25900            NodeRequest::Prompt {
25901                session: address.clone(),
25902                text: "prompt".to_owned(),
25903            },
25904            NodeRequest::Paste {
25905                session: address.clone(),
25906                text: "paste".to_owned(),
25907            },
25908            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
25909            // the same semantic capabilities as `Prompt`/`Paste`. A bare
25910            // (promptless) resume is a raw PTY relaunch and IS admitted under
25911            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
25912            // in `provider_runtime.rs`; it is deliberately not exercised here.
25913            NodeRequest::Resume {
25914                session: address.clone(),
25915                terminal_size,
25916                initial_prompt: Some("resume".to_owned()),
25917            },
25918        ] {
25919            let error = process_request_inner(
25920                &shared,
25921                77,
25922                ClientRole::Operator,
25923                request,
25924            )
25925            .await
25926            .unwrap_err();
25927            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25928        }
25929
25930        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25931        assert_eq!(shared.handle.snapshot().revision, revision_before);
25932        assert_eq!(shared.current_sequence(), history_sequence_before);
25933        let bindings = shared
25934            .session_bindings
25935            .lock()
25936            .unwrap_or_else(|poisoned| poisoned.into_inner());
25937        let binding = bindings.get(&address.session.instance_id).unwrap();
25938        assert_eq!(binding.generation, address.session.generation);
25939        assert!(binding.pending_resume.is_none());
25940        assert!(binding.record_id.is_none());
25941    }
25942
25943    #[tokio::test]
25944    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25945        let catalog = active_registry().unwrap();
25946        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25947        let primary = WorkspaceConfig::new(
25948            WorkspaceId::new("primary").unwrap(),
25949            std::env::current_dir().unwrap(),
25950        )
25951        .unwrap();
25952        let shared = NodeShared::new(
25953            handle,
25954            "fixture-token".to_owned(),
25955            NodeId::new("node-1").unwrap(),
25956            vec![primary],
25957            vec![agent("claude")],
25958        );
25959        shared
25960            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25961            .unwrap();
25962        let before = shared.snapshot();
25963        let error = process_request_inner(
25964            &shared,
25965            77,
25966            ClientRole::Operator,
25967            NodeRequest::RegisterWorkspace {
25968                workspace_id: WorkspaceId::new("foreign").unwrap(),
25969                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25970            },
25971        )
25972        .await
25973        .unwrap_err();
25974        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25975        assert_eq!(error.message, "invalid-request");
25976        assert_eq!(shared.snapshot().workspaces, before.workspaces);
25977    }
25978
25979    #[tokio::test]
25980    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25981        let catalog = active_registry().unwrap();
25982        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25983        let primary_id = WorkspaceId::new("primary").unwrap();
25984        let primary = WorkspaceConfig::new(
25985            primary_id.clone(),
25986            std::env::current_dir().unwrap(),
25987        )
25988        .unwrap();
25989        let shared = NodeShared::new(
25990            handle,
25991            "fixture-token".to_owned(),
25992            NodeId::new("node-1").unwrap(),
25993            vec![primary.clone()],
25994            vec![agent("claude")],
25995        );
25996        let secondary_id = WorkspaceId::new("secondary").unwrap();
25997        let secondary = shared
25998            .register_workspace(
25999                secondary_id.clone(),
26000                std::env::temp_dir().to_string_lossy().into_owned(),
26001            )
26002            .await
26003            .unwrap();
26004        assert_eq!(secondary.workspace_id, secondary_id);
26005        assert!(secondary.sessions.is_empty());
26006        assert_eq!(shared.snapshot().workspaces.len(), 2);
26007
26008        let duplicate_id = shared
26009            .register_workspace(
26010                secondary_id.clone(),
26011                primary.canonical_root().to_owned(),
26012            )
26013            .await
26014            .unwrap_err();
26015        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26016        let duplicate_root = shared
26017            .register_workspace(
26018                WorkspaceId::new("duplicate-root").unwrap(),
26019                windows_path_text(&secondary.canonical_root).to_owned(),
26020            )
26021            .await
26022            .unwrap_err();
26023        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26024
26025        let address = SessionAddress {
26026            workspace_id: secondary_id.clone(),
26027            session: SessionKey {
26028                instance_id: AgentInstanceId(41),
26029                generation: SessionGeneration(1),
26030            },
26031        };
26032        shared.bind_session(
26033            address.session.instance_id,
26034            secondary_id.clone(),
26035            address.session.generation,
26036        );
26037        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26038        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26039        shared.remove_binding(&address);
26040        shared.unregister_workspace(&secondary_id).unwrap();
26041        assert_eq!(shared.snapshot().workspaces.len(), 1);
26042
26043        let last = shared.unregister_workspace(&primary_id).unwrap_err();
26044        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26045        let history = shared
26046            .history
26047            .lock()
26048            .unwrap_or_else(|poisoned| poisoned.into_inner());
26049        assert!(matches!(
26050            &history.events[0],
26051            NodeEventEnvelope {
26052                sequence: 1,
26053                event: NodeEvent::WorkspaceAdded { workspace },
26054            } if workspace.workspace_id == secondary_id
26055        ));
26056        assert!(matches!(
26057            &history.events[1],
26058            NodeEventEnvelope {
26059                sequence: 2,
26060                event: NodeEvent::WorkspaceRemoved { workspace_id },
26061            } if workspace_id == &secondary_id
26062        ));
26063    }
26064
26065    #[tokio::test]
26066    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26067        let secondary_root = temporary_workspace_root("provider-session-reference-index");
26068        std::fs::create_dir_all(&secondary_root).unwrap();
26069        let catalog = active_registry().unwrap();
26070        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26071        let primary_id = WorkspaceId::new("primary").unwrap();
26072        let secondary_id = WorkspaceId::new("secondary").unwrap();
26073        let shared = NodeShared::new(
26074            handle,
26075            "fixture-token".to_owned(),
26076            NodeId::new("node-session-reference-index").unwrap(),
26077            vec![
26078                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26079                    .unwrap(),
26080                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26081            ],
26082            vec![agent("claude"), agent("codex")],
26083        );
26084        let identity = ProviderSessionIdentity {
26085            key: gate4agent_types::ProviderSessionKey::SessionId,
26086            id: "provider-session-42".to_owned(),
26087            transcript_path: None,
26088        };
26089        let index_request = |workspace_id: WorkspaceId,
26090                             provider: AgentId,
26091                             identity: ProviderSessionIdentity,
26092                             display_name: &str| {
26093            NodeRequest::IndexProviderSession {
26094                workspace_id,
26095                provider,
26096                identity,
26097                display_name: display_name.to_owned(),
26098            }
26099        };
26100        let request = index_request(
26101            primary_id.clone(),
26102            agent("claude"),
26103            identity.clone(),
26104            "release shepherd",
26105        );
26106        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26107            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26108        }));
26109        let observer = process_request_inner(
26110            &shared,
26111            77,
26112            ClientRole::Observer,
26113            request.clone(),
26114        )
26115        .await
26116        .unwrap_err();
26117        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26118        let uncontrolled = process_request_inner(
26119            &shared,
26120            77,
26121            ClientRole::Operator,
26122            request.clone(),
26123        )
26124        .await
26125        .unwrap_err();
26126        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26127        shared
26128            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26129            .unwrap();
26130
26131        let indexed = process_request_inner(
26132            &shared,
26133            77,
26134            ClientRole::Operator,
26135            request.clone(),
26136        )
26137        .await
26138        .unwrap();
26139        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26140            panic!("provider session reference index returned another response");
26141        };
26142        assert_eq!(record.state, ManagedSessionState::Dormant);
26143        assert_eq!(record.mode, SessionMode::Pty);
26144        assert_eq!(record.workspace_id, primary_id);
26145        assert_eq!(record.provider_session.as_ref(), Some(&identity));
26146        assert!(record.active_session.is_none());
26147        assert!(record.environment_profile.is_none());
26148        assert!(record.bundle.is_none());
26149        assert!(record.context.is_none());
26150
26151        let repeated = process_request_inner(
26152            &shared,
26153            77,
26154            ClientRole::Operator,
26155            index_request(
26156                primary_id.clone(),
26157                agent("claude"),
26158                identity.clone(),
26159                "ignored replacement",
26160            ),
26161        )
26162        .await
26163        .unwrap();
26164        assert!(matches!(
26165            repeated,
26166            NodeResponse::ProviderSessionIndexed { record: ref existing }
26167                if existing.record_id == record.record_id
26168                    && existing.display_name == "release shepherd"
26169        ));
26170        let conflicting_workspace = process_request_inner(
26171            &shared,
26172            77,
26173            ClientRole::Operator,
26174            index_request(
26175                secondary_id,
26176                agent("claude"),
26177                identity.clone(),
26178                "release shepherd",
26179            ),
26180        )
26181        .await
26182        .unwrap_err();
26183        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26184        let conflicting_provider = process_request_inner(
26185            &shared,
26186            77,
26187            ClientRole::Operator,
26188            index_request(
26189                primary_id.clone(),
26190                agent("codex"),
26191                identity.clone(),
26192                "release shepherd",
26193            ),
26194        )
26195        .await
26196        .unwrap_err();
26197        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26198        let transcript_rejected = process_request_inner(
26199            &shared,
26200            77,
26201            ClientRole::Operator,
26202            index_request(
26203                primary_id,
26204                agent("claude"),
26205                ProviderSessionIdentity {
26206                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26207                    ..identity
26208                },
26209                "release shepherd",
26210            ),
26211        )
26212        .await
26213        .unwrap_err();
26214        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26215        let upserts = shared
26216            .history
26217            .lock()
26218            .unwrap_or_else(|poisoned| poisoned.into_inner())
26219            .events
26220            .iter()
26221            .filter(|event| matches!(
26222                &event.event,
26223                NodeEvent::SessionRecordUpserted { record: published }
26224                    if published.record_id == record.record_id
26225            ))
26226            .count();
26227        assert_eq!(upserts, 1);
26228        std::fs::remove_dir_all(secondary_root).unwrap();
26229    }
26230
26231    #[tokio::test]
26232    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26233        let fixture_root = temporary_workspace_root("standalone-independent");
26234        let target = fixture_root.join("independent");
26235        std::fs::create_dir_all(&fixture_root).unwrap();
26236        let catalog = active_registry().unwrap();
26237        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26238        let selected = WorkspaceConfig::new(
26239            WorkspaceId::new("primary").unwrap(),
26240            std::env::current_dir().unwrap(),
26241        )
26242        .unwrap();
26243        let selected_root = selected.canonical_root().to_owned();
26244        let shared = NodeShared::new(
26245            handle,
26246            "fixture-token".to_owned(),
26247            NodeId::new("node-standalone").unwrap(),
26248            vec![selected],
26249            vec![agent("claude")],
26250        );
26251        let workspace_id = WorkspaceId::new("independent").unwrap();
26252        let request = NodeRequest::CreateStandaloneWorkspace {
26253            workspace_id: workspace_id.clone(),
26254            root: opaque_windows_path(target.to_string_lossy().into_owned()),
26255            initial_branch: Some("main".to_owned()),
26256        };
26257        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26258            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26259        }));
26260        assert!(!request_uses_unnegotiated_capability(
26261            &request,
26262            &baseline_capabilities().unwrap(),
26263        ));
26264        assert!(request_uses_unnegotiated_capability(&request, &[]));
26265
26266        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26267            .await
26268            .unwrap_err();
26269        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26270        assert!(!target.exists());
26271        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26272            .await
26273            .unwrap_err();
26274        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26275        assert!(!target.exists());
26276        shared
26277            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26278            .unwrap();
26279
26280        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26281            .await
26282            .unwrap();
26283        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26284            panic!("standalone workspace creation returned another response");
26285        };
26286        assert_eq!(workspace.workspace_id, workspace_id);
26287        assert!(target.join(".git").is_dir());
26288        let branch = run_git_bounded(
26289            target.to_str().unwrap(),
26290            &["branch", "--show-current"],
26291            GIT_OUTPUT_MAX_BYTES,
26292        )
26293        .await
26294        .unwrap();
26295        assert!(branch.success);
26296        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26297        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26298        assert_eq!(worktrees.len(), 1);
26299        let listed_root = WorkspaceConfig::new(
26300            WorkspaceId::new("listed-independent").unwrap(),
26301            &worktrees[0].path,
26302        )
26303        .unwrap();
26304        assert!(platform::roots_equal(
26305            listed_root.canonical_root(),
26306            windows_path_text(&workspace.canonical_root),
26307        ));
26308        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26309        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26310            snapshot.workspace_id == workspace_id
26311                && snapshot.canonical_root == workspace.canonical_root
26312        }));
26313        assert!(shared
26314            .history
26315            .lock()
26316            .unwrap_or_else(|poisoned| poisoned.into_inner())
26317            .events
26318            .iter()
26319            .any(|event| matches!(
26320                &event.event,
26321                NodeEvent::WorkspaceAdded { workspace }
26322                    if workspace.workspace_id == workspace_id
26323            )));
26324        std::fs::remove_dir_all(fixture_root).unwrap();
26325    }
26326
26327    #[tokio::test]
26328    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26329        let fixture_root = temporary_workspace_root("standalone-compensation");
26330        let created_target = fixture_root.join("created-target");
26331        let existing_target = fixture_root.join("existing-target");
26332        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26333        let invalid_state_path = invalid_state_parent.join("state.json");
26334        std::fs::create_dir_all(&existing_target).unwrap();
26335        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26336        let catalog = active_registry().unwrap();
26337        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26338        let primary = WorkspaceConfig::new(
26339            WorkspaceId::new("primary").unwrap(),
26340            std::env::current_dir().unwrap(),
26341        )
26342        .unwrap();
26343        let mut shared = NodeShared::new(
26344            handle,
26345            "fixture-token".to_owned(),
26346            NodeId::new("node-standalone-compensation").unwrap(),
26347            vec![primary],
26348            vec![agent("claude")],
26349        );
26350        shared.state_path = Some(invalid_state_path);
26351
26352        let created_recovery = shared
26353            .create_standalone_workspace(
26354                WorkspaceId::new("compensated").unwrap(),
26355                created_target.to_string_lossy().into_owned(),
26356                Some("main".to_owned()),
26357            )
26358            .await
26359            .unwrap_err();
26360        assert_eq!(
26361            created_recovery.code,
26362            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26363        );
26364        assert!(created_target.join(".git").is_dir());
26365        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26366            workspace.workspace_id.as_str() != "compensated"
26367        }));
26368
26369        let existing_recovery = shared
26370            .create_standalone_workspace(
26371                WorkspaceId::new("restored").unwrap(),
26372                existing_target.to_string_lossy().into_owned(),
26373                None,
26374            )
26375            .await
26376            .unwrap_err();
26377        assert_eq!(
26378            existing_recovery.code,
26379            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26380        );
26381        assert!(existing_target.is_dir());
26382        assert!(existing_target.join(".git").is_dir());
26383        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26384            workspace.workspace_id.as_str() != "restored"
26385        }));
26386
26387        let registered_empty = fixture_root.join("registered-empty");
26388        std::fs::create_dir(&registered_empty).unwrap();
26389        let catalog = active_registry().unwrap();
26390        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26391        let registered = WorkspaceConfig::new(
26392            WorkspaceId::new("registered-empty").unwrap(),
26393            &registered_empty,
26394        )
26395        .unwrap();
26396        let duplicate_shared = NodeShared::new(
26397            handle,
26398            "fixture-token".to_owned(),
26399            NodeId::new("node-standalone-duplicate-root").unwrap(),
26400            vec![registered],
26401            vec![agent("claude")],
26402        );
26403        let duplicate = duplicate_shared
26404            .create_standalone_workspace(
26405                WorkspaceId::new("duplicate-root").unwrap(),
26406                registered_empty.to_string_lossy().into_owned(),
26407                None,
26408            )
26409            .await
26410            .unwrap_err();
26411        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26412        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
26413        std::fs::remove_dir_all(fixture_root).unwrap();
26414    }
26415
26416    #[test]
26417    fn managed_record_id_collision_preserves_the_existing_record() {
26418        let catalog = active_registry().unwrap();
26419        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26420        let workspace = WorkspaceConfig::new(
26421            WorkspaceId::new("primary").unwrap(),
26422            std::env::current_dir().unwrap(),
26423        )
26424        .unwrap();
26425        let shared = NodeShared::new(
26426            handle,
26427            "fixture-token".to_owned(),
26428            NodeId::new("node-1").unwrap(),
26429            vec![workspace.clone()],
26430            vec![agent("claude")],
26431        );
26432        let record_id = SessionRecordId::new("sr-collision").unwrap();
26433        let original = ManagedSessionRecord {
26434            record_id: record_id.clone(),
26435            display_name: "original".to_owned(),
26436            provider: agent("claude"),
26437            mode: SessionMode::Pty,
26438            state: ManagedSessionState::Dormant,
26439            workspace_id: workspace.workspace_id,
26440            canonical_root: opaque_windows_path(workspace.canonical_root),
26441            provider_session: None,
26442            active_session: None,
26443            environment_profile: None,
26444            bundle: None,
26445            context_id: None,
26446            context: None,
26447            exported_context: None,
26448            task_binding: None,
26449            created_at_unix_ms: 1,
26450            updated_at_unix_ms: 1,
26451            last_error: None,
26452        };
26453        shared.insert_record(original.clone()).unwrap();
26454        let mut collision = original.clone();
26455        collision.display_name = "replacement".to_owned();
26456        let error = shared.insert_record(collision).unwrap_err();
26457        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26458        assert_eq!(shared.record(&record_id).unwrap(), original);
26459    }
26460
26461    #[test]
26462    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26463        let catalog = active_registry().unwrap();
26464        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26465        let workspace = WorkspaceConfig::new(
26466            WorkspaceId::new("primary").unwrap(),
26467            std::env::current_dir().unwrap(),
26468        )
26469        .unwrap();
26470        let shared = NodeShared::new(
26471            handle,
26472            "fixture-token".to_owned(),
26473            NodeId::new("node-1").unwrap(),
26474            vec![workspace.clone()],
26475            vec![agent("claude")],
26476        );
26477        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26478        shared
26479            .insert_record(ManagedSessionRecord {
26480                record_id: record_id.clone(),
26481                display_name: "runtime error".to_owned(),
26482                provider: agent("claude"),
26483                mode: SessionMode::Pty,
26484                state: ManagedSessionState::IdentityPending,
26485                workspace_id: workspace.workspace_id,
26486                canonical_root: opaque_windows_path(workspace.canonical_root),
26487                provider_session: None,
26488                active_session: None,
26489                environment_profile: None,
26490                bundle: None,
26491                context_id: None,
26492                context: None,
26493                exported_context: None,
26494                task_binding: None,
26495                created_at_unix_ms: 1,
26496                updated_at_unix_ms: 1,
26497                last_error: None,
26498            })
26499            .unwrap();
26500
26501        shared
26502            .mark_record_error(
26503                &record_id,
26504                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26505            )
26506            .unwrap();
26507        let record = shared.record(&record_id).unwrap();
26508        assert_eq!(record.state, ManagedSessionState::Unavailable);
26509        assert_eq!(
26510            record.last_error.as_deref(),
26511            Some("provider-runtime-failed"),
26512        );
26513        let snapshot = shared.snapshot();
26514        assert_eq!(snapshot.session_records, vec![record]);
26515    }
26516
26517    /// Regression coverage for the "operator sees `deadline` instead of the
26518    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
26519    /// able to learn a command's asynchronous kernel rejection (here,
26520    /// specifically classified as `UnsupportedTransport`) exactly once, and
26521    /// must not still find it after that -- proving the fast path this
26522    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
26523    /// loop) drains the reason instead of leaving it to be rediscovered (or
26524    /// missed) on a later poll.
26525    #[test]
26526    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26527        let catalog = active_registry().unwrap();
26528        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26529        let workspace = WorkspaceConfig::new(
26530            WorkspaceId::new("primary").unwrap(),
26531            std::env::current_dir().unwrap(),
26532        )
26533        .unwrap();
26534        let shared = NodeShared::new(
26535            handle,
26536            "fixture-token".to_owned(),
26537            NodeId::new("node-1").unwrap(),
26538            vec![workspace],
26539            vec![agent("claude")],
26540        );
26541
26542        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26543
26544        shared.record_rejected_command(
26545            CommandId(1),
26546            "agent 'claude' does not support transport Acp".to_owned(),
26547            true,
26548        );
26549        // An unrelated command_id must not see another command's reason.
26550        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26551
26552        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26553        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26554        assert!(reason.unsupported_transport);
26555
26556        // Consumed: a second read for the same command_id finds nothing.
26557        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26558
26559        // A rejection unrelated to transport support keeps its own
26560        // classification distinct, so the reader can choose the generic
26561        // `BackendOperationFailed` code instead of misreporting
26562        // `UnsupportedTransport`.
26563        shared.record_rejected_command(
26564            CommandId(3),
26565            "backend busy".to_owned(),
26566            false,
26567        );
26568        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26569        assert!(!unclassified.unsupported_transport);
26570    }
26571
26572    #[test]
26573    fn provider_identity_update_is_ignored_without_exact_session_policy() {
26574        let catalog = active_registry().unwrap();
26575        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26576        let workspace = WorkspaceConfig::new(
26577            WorkspaceId::new("primary").unwrap(),
26578            std::env::current_dir().unwrap(),
26579        )
26580        .unwrap();
26581        let shared = NodeShared::new(
26582            handle,
26583            "fixture-token".to_owned(),
26584            NodeId::new("node-identity-policy").unwrap(),
26585            vec![workspace.clone()],
26586            vec![agent("claude")],
26587        );
26588        let address = SessionAddress {
26589            workspace_id: workspace.workspace_id.clone(),
26590            session: SessionKey {
26591                instance_id: AgentInstanceId(90),
26592                generation: SessionGeneration(1),
26593            },
26594        };
26595        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26596        shared
26597            .insert_record(ManagedSessionRecord {
26598                record_id: record_id.clone(),
26599                display_name: "policy denied".to_owned(),
26600                provider: agent("claude"),
26601                mode: SessionMode::Pty,
26602                state: ManagedSessionState::IdentityPending,
26603                workspace_id: workspace.workspace_id,
26604                canonical_root: opaque_windows_path(workspace.canonical_root),
26605                provider_session: None,
26606                active_session: Some(address.clone()),
26607                environment_profile: None,
26608                bundle: None,
26609                context_id: None,
26610                context: None,
26611                exported_context: None,
26612                task_binding: None,
26613                created_at_unix_ms: 1,
26614                updated_at_unix_ms: 1,
26615                last_error: None,
26616            })
26617            .unwrap();
26618        let event = ControlEvent {
26619            sequence: 1,
26620            command_id: None,
26621            instance_id: address.session.instance_id,
26622            generation: address.session.generation,
26623            event: ControlEventKind::ProviderEvent {
26624                sequence: 1,
26625                source: gate4agent_types::ProviderSource {
26626                    family: AdapterFamily::PtySemantic,
26627                    binding: AdapterBinding::new(
26628                        gate4agent_types::AdapterId::new("claude").unwrap(),
26629                        "fixture/v1",
26630                        gate4agent_types::AdapterVerification::SyntheticFixture,
26631                    )
26632                    .unwrap(),
26633                },
26634                source_sequence: 1,
26635                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26636                    identity: gate4agent_types::ProviderSessionIdentity {
26637                        key: gate4agent_types::ProviderSessionKey::SessionId,
26638                        id: "provider-session".to_owned(),
26639                        transcript_path: None,
26640                    },
26641                },
26642            },
26643        };
26644
26645        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26646
26647        let record = shared.record(&record_id).unwrap();
26648        assert!(record.provider_session.is_none());
26649        assert_eq!(record.state, ManagedSessionState::IdentityPending);
26650        assert_eq!(record.active_session.as_ref(), Some(&address));
26651    }
26652
26653    #[test]
26654    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26655        let secondary_root = temporary_workspace_root("identity-scope");
26656        std::fs::create_dir_all(&secondary_root).unwrap();
26657        let catalog = active_registry().unwrap();
26658        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26659        let primary = WorkspaceConfig::new(
26660            WorkspaceId::new("primary").unwrap(),
26661            std::env::current_dir().unwrap(),
26662        )
26663        .unwrap();
26664        let secondary = WorkspaceConfig::new(
26665            WorkspaceId::new("secondary").unwrap(),
26666            &secondary_root,
26667        )
26668        .unwrap();
26669        let shared = NodeShared::new(
26670            handle,
26671            "fixture-token".to_owned(),
26672            NodeId::new("node-1").unwrap(),
26673            vec![primary.clone(), secondary.clone()],
26674            vec![agent("claude")],
26675        );
26676        let identity = gate4agent_types::ProviderSessionIdentity {
26677            key: gate4agent_types::ProviderSessionKey::SessionId,
26678            id: "provider-session".to_owned(),
26679            transcript_path: None,
26680        };
26681        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26682        shared
26683            .insert_record(ManagedSessionRecord {
26684                record_id: dormant_id.clone(),
26685                display_name: "dormant".to_owned(),
26686                provider: agent("claude"),
26687                mode: SessionMode::Pty,
26688                state: ManagedSessionState::Dormant,
26689                workspace_id: primary.workspace_id,
26690                canonical_root: opaque_windows_path(primary.canonical_root),
26691                provider_session: Some(identity.clone()),
26692                active_session: None,
26693                environment_profile: None,
26694                bundle: None,
26695                context_id: None,
26696                context: None,
26697                exported_context: None,
26698                task_binding: None,
26699                created_at_unix_ms: 1,
26700                updated_at_unix_ms: 1,
26701                last_error: None,
26702            })
26703            .unwrap();
26704        let address = SessionAddress {
26705            workspace_id: secondary.workspace_id.clone(),
26706            session: SessionKey {
26707                instance_id: AgentInstanceId(91),
26708                generation: SessionGeneration(1),
26709            },
26710        };
26711        let current_id = SessionRecordId::new("sr-current").unwrap();
26712        shared
26713            .insert_record(ManagedSessionRecord {
26714                record_id: current_id.clone(),
26715                display_name: "current".to_owned(),
26716                provider: agent("claude"),
26717                mode: SessionMode::Pty,
26718                state: ManagedSessionState::IdentityPending,
26719                workspace_id: secondary.workspace_id,
26720                canonical_root: opaque_windows_path(secondary.canonical_root),
26721                provider_session: None,
26722                active_session: Some(address.clone()),
26723                environment_profile: None,
26724                bundle: None,
26725                context_id: None,
26726                context: None,
26727                exported_context: None,
26728                task_binding: None,
26729                created_at_unix_ms: 2,
26730                updated_at_unix_ms: 2,
26731                last_error: None,
26732            })
26733            .unwrap();
26734        shared.bind_managed_session(
26735            &address,
26736            current_id.clone(),
26737            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26738            None,
26739        );
26740        let event = ControlEvent {
26741            sequence: 1,
26742            command_id: None,
26743            instance_id: address.session.instance_id,
26744            generation: address.session.generation,
26745            event: ControlEventKind::ProviderEvent {
26746                sequence: 1,
26747                source: gate4agent_types::ProviderSource {
26748                    family: gate4agent_types::AdapterFamily::PtySemantic,
26749                    binding: gate4agent_types::AdapterBinding::new(
26750                        gate4agent_types::AdapterId::new("claude").unwrap(),
26751                        "fixture/v1",
26752                        gate4agent_types::AdapterVerification::SyntheticFixture,
26753                    )
26754                    .unwrap(),
26755                },
26756                source_sequence: 1,
26757                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26758                    identity,
26759                },
26760            },
26761        };
26762        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
26763
26764        let binding = shared
26765            .session_bindings
26766            .lock()
26767            .unwrap_or_else(|poisoned| poisoned.into_inner())
26768            .get(&address.session.instance_id)
26769            .cloned()
26770            .unwrap();
26771        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
26772        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26773        let current = shared.record(&current_id).unwrap();
26774        assert_eq!(current.state, ManagedSessionState::Unavailable);
26775        assert_eq!(current.active_session.as_ref(), Some(&address));
26776        assert_eq!(
26777            current.last_error.as_deref(),
26778            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26779        );
26780        std::fs::remove_dir_all(secondary_root).unwrap();
26781    }
26782
26783    #[tokio::test]
26784    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26785        let root = temporary_workspace_root("semantic-provider-session");
26786        std::fs::create_dir_all(&root).unwrap();
26787        let state_path = root.join("state-v1.json");
26788        let catalog = active_registry().unwrap();
26789        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26790        let workspace = WorkspaceConfig::new(
26791            WorkspaceId::new("primary").unwrap(),
26792            &root,
26793        )
26794        .unwrap();
26795        let node_id = NodeId::new("node-semantic-identity").unwrap();
26796        let shared = NodeShared::new_with_incarnation(
26797            handle,
26798            "fixture-token".to_owned(),
26799            node_id.clone(),
26800            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26801            vec![workspace.clone()],
26802            vec![agent("claude")],
26803            ProviderRuntimeStatuses::default(),
26804            None,
26805            Vec::new(),
26806            Vec::new(),
26807            SpawnProfileRegistry::default(),
26808            None,
26809            None,
26810            None,
26811            Some(state_path.clone()),
26812            Vec::new(),
26813            Vec::new(),
26814            Vec::new(),
26815            Vec::new(),
26816            None,
26817            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26818        );
26819        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26820            key: gate4agent_types::ProviderSessionKey::SessionId,
26821            id: "provider-session".to_owned(),
26822            transcript_path: None,
26823        };
26824        let durable_id = SessionRecordId::new("sr-durable").unwrap();
26825        shared
26826            .insert_record(ManagedSessionRecord {
26827                record_id: durable_id.clone(),
26828                display_name: "durable".to_owned(),
26829                provider: agent("claude"),
26830                mode: SessionMode::Pty,
26831                state: ManagedSessionState::Dormant,
26832                workspace_id: workspace.workspace_id.clone(),
26833                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26834                provider_session: Some(persisted_identity.clone()),
26835                active_session: None,
26836                environment_profile: None,
26837                bundle: None,
26838                context_id: None,
26839                context: None,
26840                exported_context: None,
26841                task_binding: None,
26842                created_at_unix_ms: 1,
26843                updated_at_unix_ms: 1,
26844                last_error: None,
26845            })
26846            .unwrap();
26847        let address = SessionAddress {
26848            workspace_id: workspace.workspace_id.clone(),
26849            session: SessionKey {
26850                instance_id: AgentInstanceId(92),
26851                generation: SessionGeneration(2),
26852            },
26853        };
26854        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26855        shared
26856            .insert_record(ManagedSessionRecord {
26857                record_id: pending_id.clone(),
26858                display_name: "pending".to_owned(),
26859                provider: agent("claude"),
26860                mode: SessionMode::Pty,
26861                state: ManagedSessionState::IdentityPending,
26862                workspace_id: workspace.workspace_id.clone(),
26863                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26864                provider_session: None,
26865                active_session: Some(address.clone()),
26866                environment_profile: None,
26867                bundle: None,
26868                context_id: None,
26869                context: None,
26870                exported_context: None,
26871                task_binding: None,
26872                created_at_unix_ms: 2,
26873                updated_at_unix_ms: 2,
26874                last_error: None,
26875            })
26876            .unwrap();
26877        shared.bind_managed_session(
26878            &address,
26879            pending_id.clone(),
26880            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26881            None,
26882        );
26883        let observed_identity = gate4agent_types::ProviderSessionIdentity {
26884            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26885            ..persisted_identity
26886        };
26887        let event = ControlEvent {
26888            sequence: 1,
26889            command_id: None,
26890            instance_id: address.session.instance_id,
26891            generation: address.session.generation,
26892            event: ControlEventKind::ProviderEvent {
26893                sequence: 1,
26894                source: gate4agent_types::ProviderSource {
26895                    family: gate4agent_types::AdapterFamily::PtySemantic,
26896                    binding: gate4agent_types::AdapterBinding::new(
26897                        gate4agent_types::AdapterId::new("claude").unwrap(),
26898                        "fixture/v1",
26899                        gate4agent_types::AdapterVerification::SyntheticFixture,
26900                    )
26901                    .unwrap(),
26902                },
26903                source_sequence: 1,
26904                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26905                    identity: observed_identity.clone(),
26906                },
26907            },
26908        };
26909        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26910
26911        let snapshot = shared.snapshot();
26912        assert_eq!(snapshot.session_records.len(), 1);
26913        let live = &snapshot.session_records[0];
26914        assert_eq!(live.record_id, durable_id);
26915        assert_eq!(live.state, ManagedSessionState::Live);
26916        assert_eq!(live.active_session.as_ref(), Some(&address));
26917        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26918        let binding = shared
26919            .session_bindings
26920            .lock()
26921            .unwrap_or_else(|poisoned| poisoned.into_inner())
26922            .get(&address.session.instance_id)
26923            .cloned()
26924            .unwrap();
26925        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26926
26927        let mut duplicate = live.clone();
26928        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26929        duplicate.display_name = "duplicate".to_owned();
26930        duplicate.state = ManagedSessionState::Dormant;
26931        duplicate.active_session = None;
26932        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26933        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26934        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26935        assert_eq!(shared.snapshot().session_records.len(), 1);
26936
26937        let terminal_size = gate4agent_types::TerminalSize {
26938            rows: 24,
26939            columns: 80,
26940        };
26941        let (first_busy, second_busy) = tokio::join!(
26942            shared.resume_session_record(&durable_id, terminal_size, None),
26943            shared.resume_session_record(&durable_id, terminal_size, None),
26944        );
26945        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26946            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26947            assert_eq!(busy.message, "session-record-busy");
26948        }
26949
26950        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26951        assert_eq!(loaded.records.len(), 1);
26952        assert_eq!(loaded.records[0].record_id, durable_id);
26953        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26954        assert_eq!(
26955            loaded.records[0]
26956                .provider_session
26957                .as_ref()
26958                .unwrap()
26959                .transcript_path,
26960            None,
26961        );
26962        let catalog = active_registry().unwrap();
26963        let (reloaded_handle, _reloaded_runtime) =
26964            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26965        let reloaded = NodeShared::new_with_incarnation(
26966            reloaded_handle,
26967            "fixture-token".to_owned(),
26968            node_id,
26969            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26970            vec![workspace],
26971            vec![agent("claude")],
26972            ProviderRuntimeStatuses::default(),
26973            None,
26974            Vec::new(),
26975            Vec::new(),
26976            SpawnProfileRegistry::default(),
26977            None,
26978            None,
26979            None,
26980            None,
26981            loaded.records,
26982            loaded.managed_worktrees,
26983            loaded.managed_worktree_tombstones,
26984            loaded.materializations,
26985            loaded.warning,
26986            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26987        );
26988        assert_eq!(reloaded.snapshot().session_records.len(), 1);
26989        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26990        std::fs::remove_dir_all(root).unwrap();
26991    }
26992
26993    #[tokio::test]
26994    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26995        let secondary_root = temporary_workspace_root("workspace-restore");
26996        let other_root = temporary_workspace_root("workspace-conflict");
26997        std::fs::create_dir_all(&secondary_root).unwrap();
26998        std::fs::create_dir_all(&other_root).unwrap();
26999        let catalog = active_registry().unwrap();
27000        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27001        let primary = WorkspaceConfig::new(
27002            WorkspaceId::new("primary").unwrap(),
27003            std::env::current_dir().unwrap(),
27004        )
27005        .unwrap();
27006        let secondary = WorkspaceConfig::new(
27007            WorkspaceId::new("secondary").unwrap(),
27008            &secondary_root,
27009        )
27010        .unwrap();
27011        let shared = NodeShared::new(
27012            handle,
27013            "fixture-token".to_owned(),
27014            NodeId::new("node-1").unwrap(),
27015            vec![primary, secondary.clone()],
27016            vec![agent("claude")],
27017        );
27018        let record_id = SessionRecordId::new("sr-workspace").unwrap();
27019        shared
27020            .insert_record(ManagedSessionRecord {
27021                record_id: record_id.clone(),
27022                display_name: "workspace record".to_owned(),
27023                provider: agent("claude"),
27024                mode: SessionMode::Pty,
27025                state: ManagedSessionState::Dormant,
27026                workspace_id: secondary.workspace_id.clone(),
27027                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27028                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27029                    key: gate4agent_types::ProviderSessionKey::SessionId,
27030                    id: "provider-session".to_owned(),
27031                    transcript_path: None,
27032                }),
27033                active_session: None,
27034                environment_profile: None,
27035                bundle: None,
27036                context_id: None,
27037                context: None,
27038                exported_context: None,
27039                task_binding: None,
27040                created_at_unix_ms: 1,
27041                updated_at_unix_ms: 1,
27042                last_error: None,
27043            })
27044            .unwrap();
27045        shared.unregister_workspace(&secondary.workspace_id).unwrap();
27046        let unavailable = shared.record(&record_id).unwrap();
27047        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27048        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27049
27050        let conflict = shared
27051            .register_workspace(
27052                secondary.workspace_id.clone(),
27053                other_root.to_string_lossy().into_owned(),
27054            )
27055            .await
27056            .unwrap_err();
27057        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27058        shared
27059            .register_workspace(
27060                secondary.workspace_id,
27061                secondary_root.to_string_lossy().into_owned(),
27062            )
27063            .await
27064            .unwrap();
27065        let restored = shared.record(&record_id).unwrap();
27066        assert_eq!(restored.state, ManagedSessionState::Dormant);
27067        assert!(restored.last_error.is_none());
27068        std::fs::remove_dir_all(secondary_root).unwrap();
27069        std::fs::remove_dir_all(other_root).unwrap();
27070    }
27071
27072    #[test]
27073    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27074        let error = validate_workspace_request_root(
27075            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27076        )
27077        .unwrap_err();
27078        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27079        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27080        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27081    }
27082
27083    #[test]
27084    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27085        let catalog = active_registry().unwrap();
27086        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27087        let workspace = WorkspaceConfig::new(
27088            WorkspaceId::new("primary").unwrap(),
27089            std::env::current_dir().unwrap(),
27090        )
27091        .unwrap();
27092        let shared = NodeShared::new(
27093            handle,
27094            "fixture-token".to_owned(),
27095            NodeId::new("node-1").unwrap(),
27096            vec![workspace],
27097            vec![agent("claude")],
27098        );
27099        let publishers = 4_u64;
27100        let events_per_publisher = 256_u64;
27101        let start = std::sync::Barrier::new(publishers as usize + 1);
27102        let finished = AtomicU64::new(0);
27103
27104        std::thread::scope(|scope| {
27105            for _ in 0..publishers {
27106                scope.spawn(|| {
27107                    start.wait();
27108                    for _ in 0..events_per_publisher {
27109                        shared.publish(NodeEvent::ControllerChanged { controller: None });
27110                    }
27111                    finished.fetch_add(1, Ordering::Release);
27112                });
27113            }
27114            start.wait();
27115            while finished.load(Ordering::Acquire) != publishers {
27116                assert_resync_history_is_linearized(shared.resync(0));
27117                std::thread::yield_now();
27118            }
27119        });
27120        let response = shared.resync(0);
27121        assert_resync_history_is_linearized(response.clone());
27122        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27123            unreachable!("resync helper returned another response");
27124        };
27125        assert_eq!(event_sequence, publishers * events_per_publisher);
27126        assert_eq!(events.len() as u64, event_sequence);
27127    }
27128
27129    fn assert_resync_history_is_linearized(response: NodeResponse) {
27130        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27131            unreachable!("resync helper returned another response");
27132        };
27133        assert!(events.windows(2).all(|pair| {
27134            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27135        }));
27136        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27137    }
27138
27139    #[test]
27140    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27141        assert_eq!(
27142            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27143            r"C:\repo\worktree",
27144        );
27145        assert_eq!(
27146            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27147            r"\\server\share\repo",
27148        );
27149        assert!(validate_workspace_root(
27150            &WorkspaceId::new("network").unwrap(),
27151            r"\\server\share\repo",
27152        )
27153        .is_err());
27154        let workspace = WorkspaceConfig::new(
27155            WorkspaceId::new("primary").unwrap(),
27156            std::env::current_dir().unwrap(),
27157        )
27158        .unwrap();
27159        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27160    }
27161
27162    #[test]
27163    fn resume_rebinds_only_on_the_expected_authorized_generation() {
27164        let catalog = active_registry().unwrap();
27165        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27166        let workspace_id = WorkspaceId::new("primary").unwrap();
27167        let workspace = WorkspaceConfig::new(
27168            workspace_id.clone(),
27169            std::env::current_dir().unwrap(),
27170        )
27171        .unwrap();
27172        let shared = NodeShared::new(
27173            handle,
27174            "fixture-token".to_owned(),
27175            NodeId::new("node-1").unwrap(),
27176            vec![workspace],
27177            vec![agent("claude")],
27178        );
27179        let instance_id = AgentInstanceId(17);
27180        let original = SessionAddress {
27181            workspace_id,
27182            session: SessionKey {
27183                instance_id,
27184                generation: SessionGeneration(3),
27185            },
27186        };
27187        shared.bind_session(
27188            instance_id,
27189            original.workspace_id.clone(),
27190            original.session.generation,
27191        );
27192        shared
27193            .arm_resume(
27194                &original,
27195                CommandId(41),
27196                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27197            )
27198            .unwrap();
27199
27200        shared.publish_control(ControlEvent {
27201            sequence: 1,
27202            command_id: None,
27203            instance_id,
27204            generation: SessionGeneration(4),
27205            event: ControlEventKind::Running { process_id: None },
27206        });
27207        assert_eq!(
27208            shared.address_for(instance_id, SessionGeneration(3)),
27209            Some(original.clone()),
27210        );
27211        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27212
27213        shared.publish_control(ControlEvent {
27214            sequence: 2,
27215            command_id: Some(CommandId(42)),
27216            instance_id,
27217            generation: SessionGeneration(3),
27218            event: ControlEventKind::CommandRejected {
27219                message: "unrelated command".to_owned(),
27220            },
27221        });
27222
27223        shared.publish_control(ControlEvent {
27224            sequence: 3,
27225            command_id: None,
27226            instance_id,
27227            generation: SessionGeneration(4),
27228            event: ControlEventKind::ResumeAuthorized {
27229                session: gate4agent_types::ResumeSessionSummary {
27230                    key: gate4agent_types::ProviderSessionKey::SessionId,
27231                    id: "fixture-session".to_owned(),
27232                },
27233            },
27234        });
27235        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27236        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27237        assert_eq!(rebound.workspace_id, original.workspace_id);
27238        let history = shared
27239            .history
27240            .lock()
27241            .unwrap_or_else(|poisoned| poisoned.into_inner());
27242        assert!(history.events.iter().any(|envelope| matches!(
27243            &envelope.event,
27244            NodeEvent::Control { address, event }
27245                if address == &rebound
27246                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27247        )));
27248    }
27249
27250    #[test]
27251    fn all_node_pty_prompts_use_bracketed_paste_framing() {
27252        for agent in ["claude", "codex", "kimi"] {
27253            assert_eq!(
27254                prompt_framing(&AgentId::new(agent).unwrap()),
27255                PromptFraming::BracketedPaste
27256            );
27257        }
27258    }
27259
27260    fn generous_walk_deadline() -> Instant {
27261        Instant::now() + Duration::from_secs(30)
27262    }
27263
27264    #[test]
27265    fn workspace_tree_is_relative_and_skips_heavy_directories() {
27266        let root = temporary_workspace_root("tree");
27267        std::fs::create_dir_all(root.join("src")).unwrap();
27268        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27269        std::fs::create_dir_all(root.join("target/debug")).unwrap();
27270        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27271        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27272        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27273        // A renamed/agent-scoped build directory (`target-a2`-style, per the
27274        // real repository's own build layout) that carries the Cache
27275        // Directory Tagging Specification marker cargo writes into every
27276        // `--target-dir` it creates: must be skipped even though its name
27277        // does not exactly match `target`.
27278        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27279        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27280        // A directory that merely *starts with* `target` but carries no
27281        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
27282        // on the name alone, so this stays in the response.
27283        std::fs::create_dir_all(root.join("target-notes")).unwrap();
27284        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27285
27286        let (entries, truncated, _budget) =
27287            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27288        assert!(!truncated);
27289        assert!(entries.iter().any(|entry| {
27290            entry.relative_path.as_utf8() == Some("src")
27291                && entry.kind == WorkspaceEntryKind::Directory
27292        }));
27293        assert!(entries.iter().any(|entry| {
27294            entry.relative_path.as_utf8() == Some("src/lib.rs")
27295                && entry.kind == WorkspaceEntryKind::File
27296        }));
27297        assert!(entries.iter().any(|entry| {
27298            entry.relative_path.as_utf8() == Some("target-notes")
27299                && entry.kind == WorkspaceEntryKind::Directory
27300        }));
27301        assert!(entries.iter().any(|entry| {
27302            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27303                && entry.kind == WorkspaceEntryKind::File
27304        }));
27305        assert!(entries.iter().all(|entry| {
27306            let path = entry.relative_path.as_utf8().unwrap();
27307            !path.starts_with(".git")
27308                && !(path == "target" || path.starts_with("target/"))
27309                && !(path == "target-a2" || path.starts_with("target-a2/"))
27310                && !path.starts_with("node_modules")
27311        }));
27312
27313        std::fs::remove_dir_all(&root).unwrap();
27314    }
27315
27316    #[test]
27317    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27318        let root = temporary_workspace_root("tree-entry-cap");
27319        std::fs::create_dir_all(&root).unwrap();
27320        for index in 0..20 {
27321            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27322        }
27323
27324        let (entries, truncated, budget) =
27325            collect_workspace_entries(&root, generous_walk_deadline(), 5);
27326        assert!(truncated);
27327        assert!(budget.entry_cap_exceeded);
27328        assert!(!budget.time_budget_exceeded);
27329        assert!(entries.len() <= 5);
27330
27331        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27332            .expect("an entry-cap-truncated walk must report a truncation marker");
27333        assert!(inspection_truncation.walk_entry_cap_exceeded);
27334        assert!(!inspection_truncation.walk_time_budget_exceeded);
27335        assert!(!inspection_truncation.git_time_budget_exceeded);
27336        assert!(inspection_truncation.entries_visited >= 5);
27337
27338        std::fs::remove_dir_all(&root).unwrap();
27339    }
27340
27341    #[test]
27342    fn workspace_walk_within_budget_reports_no_truncation() {
27343        let root = temporary_workspace_root("tree-no-truncation");
27344        std::fs::create_dir_all(&root).unwrap();
27345        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27346
27347        let (_entries, truncated, budget) =
27348            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27349        assert!(!truncated);
27350        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27351
27352        std::fs::remove_dir_all(&root).unwrap();
27353    }
27354
27355    #[test]
27356    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27357        let root = temporary_workspace_root("tree-prefix-siblings");
27358        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27359        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27360        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27361        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27362
27363        let (entries, truncated, _budget) =
27364            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27365        assert!(!truncated);
27366        let paths = entries
27367            .iter()
27368            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27369            .collect::<Vec<_>>();
27370        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27371        assert_eq!(
27372            paths,
27373            vec![
27374                "gate4agent-c2".to_owned(),
27375                "gate4agent-c2-client".to_owned(),
27376                "gate4agent-c2-client/lib.rs".to_owned(),
27377                "gate4agent-c2/src".to_owned(),
27378                "gate4agent-c2/src/lib.rs".to_owned(),
27379            ],
27380        );
27381
27382        std::fs::remove_dir_all(&root).unwrap();
27383    }
27384
27385    #[test]
27386    fn git_parsers_cap_status_and_commit_results() {
27387        let mut status_output = Vec::new();
27388        for index in 0..=GIT_STATUS_MAX_ENTRIES {
27389            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27390        }
27391        let mut snapshot = git_snapshot_for_parser();
27392        parse_git_status(&status_output, &mut snapshot);
27393        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27394        assert!(snapshot.truncated);
27395        assert_eq!(snapshot.status[0].index_status, " ");
27396        assert_eq!(snapshot.status[0].worktree_status, "M");
27397
27398        snapshot.truncated = false;
27399        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27400            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27401            .collect::<String>();
27402        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27403        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27404        assert!(snapshot.truncated);
27405    }
27406
27407    #[test]
27408    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27409        let mut snapshot = git_snapshot_for_parser();
27410        parse_git_status(
27411            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27412            &mut snapshot,
27413        );
27414
27415        assert!(!snapshot.truncated);
27416        assert_eq!(snapshot.status.len(), 3);
27417        assert_eq!(snapshot.status[0].index_status, "R");
27418        assert_eq!(snapshot.status[0].worktree_status, " ");
27419        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27420        assert_eq!(
27421            snapshot.status[0]
27422                .previous_path
27423                .as_ref()
27424                .and_then(RepositoryPath::as_utf8),
27425            Some("src/previous name.rs"),
27426        );
27427        assert_eq!(snapshot.status[1].index_status, " ");
27428        assert_eq!(snapshot.status[1].worktree_status, "C");
27429        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27430        assert_eq!(
27431            snapshot.status[1]
27432                .previous_path
27433                .as_ref()
27434                .and_then(RepositoryPath::as_utf8),
27435            Some("src/original.rs"),
27436        );
27437        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27438        assert!(snapshot.status[2].previous_path.is_none());
27439    }
27440
27441    #[test]
27442    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27443        let mut snapshot = git_snapshot_for_parser();
27444        parse_git_status(
27445            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
27446            &mut snapshot,
27447        );
27448
27449        assert!(snapshot.truncated);
27450        assert_eq!(snapshot.status.len(), 1);
27451        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27452        assert!(snapshot.status[0].previous_path.is_none());
27453    }
27454
27455    #[tokio::test]
27456    async fn observer_can_browse_host_directories_without_controller() {
27457        let root = temporary_workspace_root("host-directory-observer");
27458        std::fs::create_dir_all(root.join("directory-b")).unwrap();
27459        std::fs::create_dir_all(root.join("directory-a")).unwrap();
27460        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27461        let workspace_id = WorkspaceId::new("primary").unwrap();
27462        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27463        let catalog = active_registry().unwrap();
27464        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27465        let shared = NodeShared::new(
27466            handle,
27467            "fixture-token".to_owned(),
27468            NodeId::new("node-1").unwrap(),
27469            vec![workspace],
27470            vec![agent("claude")],
27471        );
27472        let request = NodeRequest::BrowseHostDirectories {
27473            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27474            after: None,
27475        };
27476        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27477            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27478        }));
27479        assert!(!request_uses_unnegotiated_capability(
27480            &request,
27481            &baseline_capabilities().unwrap(),
27482        ));
27483        assert!(request_uses_unnegotiated_capability(&request, &[]));
27484
27485        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27486            .await
27487            .unwrap();
27488        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27489            panic!("host directory browse returned another response");
27490        };
27491        assert_eq!(
27492            listing
27493                .entries
27494                .iter()
27495                .map(|entry| entry.display_name.as_str())
27496                .collect::<Vec<_>>(),
27497            vec!["directory-a", "directory-b"],
27498        );
27499        assert!(listing.entries.iter().all(|entry| !entry.is_link));
27500        assert!(!listing.incomplete);
27501        std::fs::remove_dir_all(root).unwrap();
27502    }
27503
27504    #[tokio::test]
27505    async fn observer_can_inspect_only_a_registered_workspace() {
27506        let root = temporary_workspace_root("observer");
27507        std::fs::create_dir_all(root.join("src")).unwrap();
27508        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27509        let workspace_id = WorkspaceId::new("primary").unwrap();
27510        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27511        let catalog = active_registry().unwrap();
27512        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27513        let shared = NodeShared::new(
27514            handle,
27515            "fixture-token".to_owned(),
27516            NodeId::new("node-1").unwrap(),
27517            vec![workspace],
27518            vec![agent("claude")],
27519        );
27520
27521        let response = process_request_inner(
27522            &shared,
27523            77,
27524            ClientRole::Observer,
27525            NodeRequest::InspectWorkspace {
27526                workspace_id: workspace_id.clone(),
27527            },
27528        )
27529        .await
27530        .unwrap();
27531        let NodeResponse::WorkspaceInspected { inspection } = response else {
27532            panic!("workspace inspection returned another response");
27533        };
27534        assert_eq!(inspection.workspace_id, workspace_id);
27535        assert!(inspection.entries.iter().any(|entry| {
27536            entry.relative_path.as_utf8() == Some("src/main.rs")
27537        }));
27538
27539        let unknown = process_request_inner(
27540            &shared,
27541            77,
27542            ClientRole::Observer,
27543            NodeRequest::InspectWorkspace {
27544                workspace_id: WorkspaceId::new("unknown").unwrap(),
27545            },
27546        )
27547        .await
27548        .unwrap_err();
27549        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27550        std::fs::remove_dir_all(&root).unwrap();
27551    }
27552
27553    #[test]
27554    fn context_pack_source_accepts_only_running_or_successful_exit() {
27555        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27556        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27557            exit_code: Some(0),
27558        }));
27559        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27560            exit_code: Some(1),
27561        }));
27562        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27563            exit_code: None,
27564        }));
27565        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27566            message: "provider failed".to_owned(),
27567        }));
27568        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27569        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27570        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27571    }
27572
27573    #[test]
27574    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27575        let git = git_snapshot_for_parser();
27576        let first = ContextPackRepositoryObservation {
27577            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27578            git: git.clone(),
27579            files: Vec::new(),
27580        };
27581        let second = ContextPackRepositoryObservation {
27582            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27583            git,
27584            files: Vec::new(),
27585        };
27586
27587        let error = stable_context_pack_repository(first, second).unwrap_err();
27588
27589        assert_eq!(
27590            error.code,
27591            NodeFailureCode::ContextPackMaterializationFailed,
27592        );
27593    }
27594
27595    #[test]
27596    fn context_pack_repository_rejects_changed_file_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![ContextPackRepositoryFileSource::utf8(
27602                "README.md",
27603                "first\n".to_owned(),
27604                6,
27605            )],
27606        };
27607        let second = ContextPackRepositoryObservation {
27608            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27609            git,
27610            files: vec![ContextPackRepositoryFileSource::utf8(
27611                "README.md",
27612                "other\n".to_owned(),
27613                6,
27614            )],
27615        };
27616
27617        let error = stable_context_pack_repository(first, second).unwrap_err();
27618
27619        assert_eq!(
27620            error.code,
27621            NodeFailureCode::ContextPackMaterializationFailed,
27622        );
27623    }
27624
27625    #[tokio::test]
27626    async fn native_session_catalog_zero_active_session_fixture() {
27627        let workspace_root = temporary_workspace_root("native-catalog-workspace");
27628        let history_root = temporary_workspace_root("native-catalog-history");
27629        let outside_root = temporary_workspace_root("native-catalog-outside");
27630        std::fs::create_dir_all(&workspace_root).unwrap();
27631        std::fs::create_dir_all(&history_root).unwrap();
27632        std::fs::create_dir_all(&outside_root).unwrap();
27633        let transcript = |session_id: &str, cwd: &Path, title: &str| {
27634            [
27635                serde_json::json!({
27636                    "type": "user",
27637                    "sessionId": session_id,
27638                    "cwd": cwd.to_str().unwrap(),
27639                    "message": { "content": "raw user text must not cross the catalog wire" }
27640                }),
27641                serde_json::json!({
27642                    "type": "assistant",
27643                    "sessionId": session_id,
27644                    "cwd": cwd.to_str().unwrap(),
27645                    "message": { "model": "claude-sonnet", "content": "raw answer" }
27646                }),
27647                serde_json::json!({
27648                    "type": "ai-title",
27649                    "sessionId": session_id,
27650                    "aiTitle": title
27651                }),
27652            ]
27653            .into_iter()
27654            .map(|record| record.to_string())
27655            .collect::<Vec<_>>()
27656            .join("\n")
27657        };
27658        std::fs::write(
27659            history_root.join("stable-session.jsonl"),
27660            transcript("stable-session", &workspace_root, "Workspace review"),
27661        )
27662        .unwrap();
27663        std::fs::write(
27664            history_root.join("outside-session.jsonl"),
27665            transcript("outside-session", &outside_root, "Outside review"),
27666        )
27667        .unwrap();
27668        let history = NativeHistoryConfig::new(vec![
27669            NativeHistoryRoot::new(
27670                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27671                HistorySourceLayout::SingleNdjson,
27672                history_root.clone(),
27673            )
27674            .unwrap(),
27675        ])
27676        .unwrap();
27677        let catalog = active_registry().unwrap();
27678        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27679        assert!(handle.snapshot().sessions.is_empty());
27680        let workspace_id = WorkspaceId::new("primary").unwrap();
27681        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27682        let mut shared = NodeShared::new(
27683            handle,
27684            "fixture-token".to_owned(),
27685            NodeId::new("node-native-catalog").unwrap(),
27686            vec![workspace],
27687            vec![agent("claude")],
27688        );
27689        shared.native_session_catalog = Some(Arc::new(Mutex::new(
27690            NativeSessionCatalogAuthority::new(history),
27691        )));
27692        let route = NativeSessionCatalogRoute::workspace(
27693            workspace_id.clone(),
27694            agent("claude"),
27695        );
27696        let request = NodeRequest::CatalogNativeSessions {
27697            route: route.clone(),
27698            limit: 2,
27699        };
27700        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27701            .await
27702            .unwrap_err();
27703        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27704        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27705            .await
27706            .unwrap();
27707        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27708            panic!("native catalog returned a different response");
27709        };
27710        let summary = summary.expect("node must emit native session catalog summary metadata");
27711        assert_eq!(entries.len(), 1);
27712        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27713        assert_eq!(entries[0].external_group, None);
27714        assert_eq!(entries[0].record_id, None);
27715        assert_eq!(entries[0].title, None);
27716        assert_eq!(entries[0].model, None);
27717        assert_eq!(entries[0].message_count, 0);
27718        let encoded_entries = serde_json::to_string(&entries).unwrap();
27719        assert!(!encoded_entries.contains("stable-session"));
27720        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27721        let page_request = NodeRequest::PageNativeSessions {
27722            route: route.clone(),
27723            window: crate::protocol::NativeSessionCatalogWindow::Recent,
27724            catalog_revision: summary.catalog_revision,
27725            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27726            after_selection_id: None,
27727            limit: 2,
27728        };
27729        let observer = process_request_inner(
27730            &shared,
27731            7,
27732            ClientRole::Observer,
27733            page_request.clone(),
27734        )
27735        .await
27736        .unwrap_err();
27737        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27738        let page_response = process_request_inner(
27739            &shared,
27740            7,
27741            ClientRole::Operator,
27742            page_request.clone(),
27743        )
27744        .await
27745        .unwrap();
27746        assert!(matches!(
27747            page_response,
27748            NodeResponse::NativeSessionsPaged { page, .. }
27749                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27750        ));
27751        let stale = process_request_inner(
27752            &shared,
27753            7,
27754            ClientRole::Operator,
27755            NodeRequest::PageNativeSessions {
27756                route: route.clone(),
27757                window: crate::protocol::NativeSessionCatalogWindow::Recent,
27758                catalog_revision: summary.catalog_revision.wrapping_add(1),
27759                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27760                after_selection_id: None,
27761                limit: 2,
27762            },
27763        )
27764        .await
27765        .unwrap_err();
27766        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27767        let selection = NativeSessionSelection {
27768            route: route.clone(),
27769            catalog_revision: summary.catalog_revision,
27770            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27771            selection_id: entries[0].selection_id.clone(),
27772        };
27773        let preview_response = process_request_inner(
27774            &shared,
27775            7,
27776            ClientRole::Operator,
27777            NodeRequest::PreviewNativeSession {
27778                selection: selection.clone(),
27779                message_limit: 1,
27780            },
27781        )
27782        .await
27783        .unwrap();
27784        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27785            panic!("native preview returned a different response");
27786        };
27787        assert_eq!(preview.messages.len(), 1);
27788        assert!(preview.message_count_exact);
27789        assert_eq!(preview.messages[0].text, "raw answer");
27790        assert!(preview.truncated);
27791
27792        let index_request = NodeRequest::IndexNativeSession {
27793            selection: selection.clone(),
27794            display_name: "Saved Claude".to_owned(),
27795        };
27796        let observer = process_request_inner(
27797            &shared,
27798            7,
27799            ClientRole::Observer,
27800            index_request.clone(),
27801        )
27802        .await
27803        .unwrap_err();
27804        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27805        let without_controller = process_request_inner(
27806            &shared,
27807            7,
27808            ClientRole::Operator,
27809            index_request.clone(),
27810        )
27811        .await
27812        .unwrap_err();
27813        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27814        let acquired = process_request_inner(
27815            &shared,
27816            7,
27817            ClientRole::Operator,
27818            NodeRequest::AcquireController { lease_ms: 5_000 },
27819        )
27820        .await
27821        .unwrap();
27822        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27823        let indexed = process_request_inner(
27824            &shared,
27825            7,
27826            ClientRole::Operator,
27827            index_request,
27828        )
27829        .await
27830        .unwrap();
27831        assert!(indexed.native_session_index_contract_is_valid());
27832        let NodeResponse::NativeSessionIndexed {
27833            selection: echoed_selection,
27834            record,
27835        } = indexed else {
27836            panic!("native session index returned a different response");
27837        };
27838        assert_eq!(echoed_selection, selection);
27839        let recataloged = process_request_inner(
27840            &shared,
27841            7,
27842            ClientRole::Operator,
27843            NodeRequest::CatalogNativeSessions {
27844                route,
27845                limit: 2,
27846            },
27847        )
27848        .await
27849        .unwrap();
27850        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27851            panic!("native catalog returned a different response after indexing");
27852        };
27853        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27854
27855        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27856        let external_catalog = process_request_inner(
27857            &shared,
27858            7,
27859            ClientRole::Operator,
27860            NodeRequest::CatalogNativeSessions {
27861                route: unregistered_route.clone(),
27862                limit: 2,
27863            },
27864        )
27865        .await
27866        .unwrap();
27867        let external_json = serde_json::to_string(&external_catalog).unwrap();
27868        assert!(!external_json.contains("outside-session"));
27869        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27870        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27871        assert!(!external_json.contains("\"session_id\""));
27872        assert!(!external_json.contains("\"cwd\""));
27873        assert!(!external_json.contains("\"path\""));
27874        let NodeResponse::NativeSessionsCataloged {
27875            route: echoed_route,
27876            entries: external_entries,
27877            summary: Some(external_summary),
27878        } = external_catalog
27879        else {
27880            panic!("unregistered native catalog returned a different response");
27881        };
27882        assert_eq!(echoed_route, unregistered_route);
27883        assert_eq!(external_entries.len(), 1);
27884        assert_eq!(external_entries[0].record_id, None);
27885        let external_group = external_entries[0]
27886            .external_group
27887            .as_ref()
27888            .expect("unregistered session must have an external group");
27889        assert_eq!(
27890            external_group.kind,
27891            gate4agent_types::NativeSessionExternalGroupKind::Project,
27892        );
27893        assert!(external_group.group_id.starts_with("external-"));
27894        assert_eq!(
27895            external_group.display_name,
27896            outside_root.file_name().unwrap().to_string_lossy(),
27897        );
27898        let external_selection = NativeSessionSelection {
27899            route: unregistered_route,
27900            catalog_revision: external_summary.catalog_revision,
27901            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27902            selection_id: external_entries[0].selection_id.clone(),
27903        };
27904        let external_preview = process_request_inner(
27905            &shared,
27906            7,
27907            ClientRole::Operator,
27908            NodeRequest::PreviewNativeSession {
27909                selection: external_selection.clone(),
27910                message_limit: 1,
27911            },
27912        )
27913        .await
27914        .unwrap();
27915        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27916        assert!(!external_preview_json.contains("outside-session"));
27917        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27918        assert!(!external_preview_json.contains("\"session_id\""));
27919        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27920            panic!("unregistered native preview returned a different response");
27921        };
27922        assert_eq!(preview.messages.len(), 1);
27923        let external_index = process_request_inner(
27924            &shared,
27925            7,
27926            ClientRole::Operator,
27927            NodeRequest::IndexNativeSession {
27928                selection: external_selection,
27929                display_name: "External Claude".to_owned(),
27930            },
27931        )
27932        .await
27933        .unwrap_err();
27934        assert_eq!(
27935            external_index.code,
27936            NodeFailureCode::WorkspaceRegistrationRequired,
27937        );
27938        let record_response = process_request_inner(
27939            &shared,
27940            7,
27941            ClientRole::Operator,
27942            NodeRequest::PreviewSessionRecord {
27943                record_id: record.record_id.clone(),
27944                message_limit: 2,
27945            },
27946        )
27947        .await
27948        .unwrap();
27949        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27950            panic!("record preview returned a different response");
27951        };
27952        assert_eq!(record_id, record.record_id);
27953        assert_eq!(preview.messages.len(), 2);
27954        let encoded = serde_json::to_string(&preview).unwrap();
27955        assert!(!encoded.contains("stable-session"));
27956        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27957        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27958            panic!("record preview history summary returned a different response");
27959        };
27960        let summaries = events
27961            .iter()
27962            .filter_map(|envelope| match &envelope.event {
27963                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27964                    if record_id == &record.record_id =>
27965                {
27966                    Some(*summary)
27967                }
27968                _ => None,
27969            })
27970            .collect::<Vec<_>>();
27971        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27972        let summary_json = serde_json::to_string(&summaries).unwrap();
27973        for private in [
27974            "stable-session",
27975            "raw user text must not cross the catalog wire",
27976            "raw answer",
27977            "Workspace review",
27978            "claude-sonnet",
27979        ] {
27980            assert!(!summary_json.contains(private), "{private}");
27981        }
27982        let identity = record.provider_session.clone().unwrap();
27983        assert_eq!(
27984            shared.revalidate_session_record_preview(&record, &identity),
27985            Ok(())
27986        );
27987        shared.remove_record_memory(&record.record_id);
27988        assert_eq!(
27989            shared
27990                .revalidate_session_record_preview(&record, &identity)
27991                .unwrap_err()
27992                .code,
27993            NodeFailureCode::UnknownSessionRecord
27994        );
27995        let released = process_request_inner(
27996            &shared,
27997            7,
27998            ClientRole::Operator,
27999            NodeRequest::ReleaseController,
28000        )
28001        .await
28002        .unwrap();
28003        assert!(matches!(released, NodeResponse::Controller { controller: None }));
28004        assert!(shared.controller_state().is_none());
28005        std::fs::remove_dir_all(workspace_root).unwrap();
28006        std::fs::remove_dir_all(history_root).unwrap();
28007        std::fs::remove_dir_all(outside_root).unwrap();
28008    }
28009
28010    #[tokio::test]
28011    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28012        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28013        std::fs::create_dir_all(&history_root).unwrap();
28014        let history = NativeHistoryConfig::new(vec![
28015            NativeHistoryRoot::new(
28016                gate4agent_types::AdapterId::new("claude-code").unwrap(),
28017                HistorySourceLayout::SingleNdjson,
28018                history_root.clone(),
28019            )
28020            .unwrap(),
28021        ])
28022        .unwrap();
28023        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28024        let held = catalog.lock().unwrap();
28025        let result = timeout(
28026            Duration::from_secs(1),
28027            run_native_session_catalog_operation(
28028                Arc::clone(&catalog),
28029                "native session catalog",
28030                |_| Ok::<(), std::convert::Infallible>(()),
28031            ),
28032        )
28033        .await
28034        .expect("concurrent native catalog request must not wait for the operation deadline")
28035        .unwrap_err();
28036        assert_eq!(result.code, NodeFailureCode::BackendBusy);
28037        drop(held);
28038        std::fs::remove_dir_all(history_root).unwrap();
28039    }
28040
28041    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28042        let unique = std::time::SystemTime::now()
28043            .duration_since(std::time::UNIX_EPOCH)
28044            .unwrap()
28045            .as_nanos();
28046        std::env::temp_dir().join(format!(
28047            "gate4agent-node-{label}-{}-{unique}",
28048            std::process::id(),
28049        ))
28050    }
28051
28052    fn git_snapshot_for_parser() -> GitSnapshot {
28053        GitSnapshot {
28054            is_repository: true,
28055            branch: Some("main".to_owned()),
28056            status: Vec::new(),
28057            recent_commits: Vec::new(),
28058            worktrees: Vec::new(),
28059            managed_worktree: None,
28060            truncated: false,
28061            diagnostic: None,
28062        }
28063    }
28064
28065    #[tokio::test]
28066    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28067        let root = temporary_workspace_root("managed-git-scope");
28068        std::fs::create_dir_all(&root).unwrap();
28069        run_test_git(&root, &["init"]);
28070        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28071        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28072        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28073        run_test_git(&root, &["add", "seed.txt"]);
28074        run_test_git(&root, &["commit", "-m", "seed"]);
28075        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28076
28077        let workspace_id = WorkspaceId::new("managed-a").unwrap();
28078        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28079        let catalog = active_registry().unwrap();
28080        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28081        let shared = NodeShared::new(
28082            handle,
28083            "fixture-token".to_owned(),
28084            NodeId::new("node-1").unwrap(),
28085            vec![workspace],
28086            vec![agent("claude")],
28087        );
28088        let mut lease = ManagedWorktreeLeaseRecord {
28089            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28090            source_workspace_id: WorkspaceId::new("primary").unwrap(),
28091            workspace_id: workspace_id.clone(),
28092            profile_id: WorktreeProfileId::new("default").unwrap(),
28093            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28094            target_root: root.to_string_lossy().into_owned(),
28095            branch: "gate4agent/a".to_owned(),
28096            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28097            expected_head: None,
28098            retention: ManagedWorktreeRetention::Retain,
28099            state: ManagedWorktreeLeaseState::InUse,
28100            session_holders: vec![ManagedWorktreeSessionHolder {
28101                incarnation_id: shared.incarnation_id,
28102                instance_id: AgentInstanceId(7),
28103                generation: SessionGeneration(1),
28104            }],
28105            record_holders: Vec::new(),
28106            cleanup_failure: None,
28107            created_at_unix_ms: 1,
28108            updated_at_unix_ms: 1,
28109        };
28110        *shared
28111            .managed_worktrees
28112            .lock()
28113            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28114            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28115
28116        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28117        let scope = inspection.git.managed_worktree.unwrap();
28118        assert_eq!(scope.lease_id, lease.lease_id);
28119        assert_eq!(scope.branch, "gate4agent/a");
28120        assert_eq!(scope.active_session_count, 1);
28121
28122        lease.branch = "gate4agent/b".to_owned();
28123        *shared
28124            .managed_worktrees
28125            .lock()
28126            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28127            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28128        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28129        assert!(mismatch.git.managed_worktree.is_none());
28130        std::fs::remove_dir_all(root).unwrap();
28131    }
28132
28133    #[tokio::test]
28134    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28135        let root = temporary_workspace_root("workspace-file-write-controller");
28136        std::fs::create_dir_all(root.join("src")).unwrap();
28137        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28138        let workspace_id = WorkspaceId::new("primary").unwrap();
28139        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28140        let catalog = active_registry().unwrap();
28141        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28142        let shared = NodeShared::new(
28143            handle,
28144            "fixture-token".to_owned(),
28145            NodeId::new("node-1").unwrap(),
28146            vec![workspace],
28147            vec![agent("claude")],
28148        );
28149        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28150        let expected_revision = workspace_file_revision(b"old\n");
28151        let request = NodeRequest::WriteWorkspaceFile {
28152            workspace_id: workspace_id.clone(),
28153            path: path.clone(),
28154            expected_revision: expected_revision.clone(),
28155            text: "new\n".to_owned(),
28156        };
28157        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28158            .await
28159            .unwrap_err();
28160        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28161        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28162            .await
28163            .unwrap_err();
28164        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28165        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28166        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28167        let NodeResponse::WorkspaceFileWritten { file } = response else {
28168            panic!("workspace file write returned another response");
28169        };
28170        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28171        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28172        let stale = process_request_inner(
28173            &shared,
28174            77,
28175            ClientRole::Operator,
28176            NodeRequest::WriteWorkspaceFile {
28177                workspace_id,
28178                path,
28179                expected_revision,
28180                text: "lost update\n".to_owned(),
28181            },
28182        )
28183        .await
28184        .unwrap_err();
28185        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28186        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28187        std::fs::remove_dir_all(root).unwrap();
28188    }
28189
28190    #[tokio::test]
28191    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28192        let root = temporary_workspace_root("workspace-entry-create-controller");
28193        std::fs::create_dir_all(root.join("src")).unwrap();
28194        let workspace_id = WorkspaceId::new("primary").unwrap();
28195        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28196        let catalog = active_registry().unwrap();
28197        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28198        let shared = NodeShared::new(
28199            handle,
28200            "fixture-token".to_owned(),
28201            NodeId::new("node-1").unwrap(),
28202            vec![workspace],
28203            vec![agent("claude")],
28204        );
28205        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28206        let request = NodeRequest::CreateWorkspaceFile {
28207            workspace_id: workspace_id.clone(),
28208            path: path.clone(),
28209        };
28210        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28211            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28212        }));
28213        assert!(!request_uses_unnegotiated_capability(
28214            &request,
28215            &baseline_capabilities().unwrap(),
28216        ));
28217        assert!(request_uses_unnegotiated_capability(&request, &[]));
28218        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28219            .await
28220            .unwrap_err();
28221        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28222        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28223            .await
28224            .unwrap_err();
28225        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28226        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28227
28228        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28229            .await
28230            .unwrap();
28231        let NodeResponse::WorkspaceFileCreated { file } = response else {
28232            panic!("workspace file create returned another response");
28233        };
28234        assert_eq!(file.workspace_id, workspace_id);
28235        assert_eq!(file.path, path);
28236        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28237        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28238
28239        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28240            .await
28241            .unwrap_err();
28242        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28243
28244        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28245        let response = process_request_inner(
28246            &shared,
28247            77,
28248            ClientRole::Operator,
28249            NodeRequest::CreateWorkspaceDirectory {
28250                workspace_id: workspace_id.clone(),
28251                path: directory_path.clone(),
28252            },
28253        )
28254        .await
28255        .unwrap();
28256        assert_eq!(
28257            response,
28258            NodeResponse::WorkspaceDirectoryCreated {
28259                workspace_id: workspace_id.clone(),
28260                entry: WorkspaceEntry {
28261                    relative_path: directory_path,
28262                    kind: WorkspaceEntryKind::Directory,
28263                },
28264            },
28265        );
28266        assert!(root.join("src/nested").is_dir());
28267
28268        let missing_parent = process_request_inner(
28269            &shared,
28270            77,
28271            ClientRole::Operator,
28272            NodeRequest::CreateWorkspaceFile {
28273                workspace_id: workspace_id.clone(),
28274                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28275            },
28276        )
28277        .await
28278        .unwrap_err();
28279        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28280        let non_directory_parent = process_request_inner(
28281            &shared,
28282            77,
28283            ClientRole::Operator,
28284            NodeRequest::CreateWorkspaceFile {
28285                workspace_id,
28286                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28287            },
28288        )
28289        .await
28290        .unwrap_err();
28291        assert_eq!(
28292            non_directory_parent.code,
28293            NodeFailureCode::RepositoryParentNotDirectory,
28294        );
28295        std::fs::remove_dir_all(root).unwrap();
28296    }
28297
28298    #[tokio::test]
28299    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28300        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28301        std::fs::create_dir_all(root.join("src")).unwrap();
28302        #[cfg(windows)]
28303        let root = std::fs::canonicalize(root).unwrap();
28304        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28305        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28306        let barrier = Arc::new(std::sync::Barrier::new(2));
28307        let done = Arc::new(AtomicBool::new(false));
28308        let task_commit_state = Arc::clone(&commit_state);
28309        let task_barrier = Arc::clone(&barrier);
28310        let task_done = Arc::clone(&done);
28311        let task_root = root.clone();
28312        let task = tokio::task::spawn_blocking(move || {
28313            task_barrier.wait();
28314            let result = create_workspace_file_on_disk(
28315                &task_root,
28316                &path,
28317                &task_commit_state,
28318            );
28319            task_done.store(true, Ordering::Release);
28320            result
28321        });
28322
28323        let error = settle_workspace_entry_create(
28324            task,
28325            Arc::clone(&commit_state),
28326            Duration::from_millis(10),
28327            "fixture deadline",
28328            "fixture task failed",
28329        )
28330        .await
28331        .unwrap_err();
28332        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28333        assert_eq!(
28334            commit_state.load(Ordering::Acquire),
28335            WORKSPACE_ENTRY_CREATE_CANCELED,
28336        );
28337
28338        barrier.wait();
28339        timeout(Duration::from_secs(1), async {
28340            while !done.load(Ordering::Acquire) {
28341                tokio::task::yield_now().await;
28342            }
28343        })
28344        .await
28345        .expect("canceled blocking create did not settle");
28346        assert!(!root.join("src/late.rs").exists());
28347        std::fs::remove_dir_all(root).unwrap();
28348    }
28349}
28350
28351#[cfg(test)]
28352mod provisional_spawn_tests {
28353    use super::*;
28354    use std::sync::Arc;
28355
28356    fn shared() -> NodeShared {
28357        let catalog = active_registry().unwrap();
28358        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28359        let workspace = WorkspaceConfig::new(
28360            WorkspaceId::new("primary").unwrap(),
28361            std::env::current_dir().unwrap(),
28362        )
28363        .unwrap()
28364        .with_worktree_service_mode(WorktreeServiceMode::Manual);
28365        NodeShared::new(
28366            handle,
28367            "fixture-token".to_owned(),
28368            NodeId::new("node-provisional-test").unwrap(),
28369            vec![workspace],
28370            vec![AgentId::new("claude").unwrap()],
28371        )
28372    }
28373
28374    /// Provisional draft map: sessionize by instance id only; 50 sequential
28375    /// mark/claim cycles never collide, and a claimed draft is never reaped.
28376    #[test]
28377    fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28378        let shared = shared();
28379        let ws = WorkspaceId::new("primary").unwrap();
28380        let mut ids = Vec::with_capacity(50);
28381        for i in 1u64..=50 {
28382            let id = AgentInstanceId(i);
28383            ids.push(id);
28384            let address = SessionAddress {
28385                workspace_id: ws.clone(),
28386                session: SessionKey {
28387                    instance_id: id,
28388                    generation: SessionGeneration(1),
28389                },
28390            };
28391            shared.mark_spawn_provisional(address);
28392        }
28393        assert_eq!(
28394            shared
28395                .provisional_spawns
28396                .lock()
28397                .unwrap_or_else(|p| p.into_inner())
28398                .len(),
28399            50
28400        );
28401        for id in ids.iter().step_by(2) {
28402            shared.sessionize_spawn(*id);
28403        }
28404        assert_eq!(
28405            shared
28406                .provisional_spawns
28407                .lock()
28408                .unwrap_or_else(|p| p.into_inner())
28409                .len(),
28410            25
28411        );
28412        assert!(shared.take_due_provisional_spawns().is_empty());
28413        {
28414            let mut map = shared
28415                .provisional_spawns
28416                .lock()
28417                .unwrap_or_else(|p| p.into_inner());
28418            let past = Instant::now() - Duration::from_secs(91);
28419            for (_, (_, at)) in map.iter_mut() {
28420                *at = past;
28421            }
28422        }
28423        let due = shared.take_due_provisional_spawns();
28424        assert_eq!(due.len(), 25);
28425        let due_ids: std::collections::BTreeSet<_> =
28426            due.iter().map(|a| a.session.instance_id.0).collect();
28427        let expected: std::collections::BTreeSet<_> =
28428            ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28429        assert_eq!(due_ids, expected);
28430        assert!(shared
28431            .provisional_spawns
28432            .lock()
28433            .unwrap_or_else(|p| p.into_inner())
28434            .is_empty());
28435    }
28436
28437    /// Twenty parallel provisional marks must each get their own instance key;
28438    /// killing/sessionizing one id must not touch the others.
28439    #[test]
28440    fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28441        let shared = Arc::new(shared());
28442        let ws = WorkspaceId::new("primary").unwrap();
28443        let mut handles = Vec::new();
28444        for i in 1u64..=20 {
28445            let shared = Arc::clone(&shared);
28446            let ws = ws.clone();
28447            handles.push(std::thread::spawn(move || {
28448                let id = AgentInstanceId(i);
28449                let address = SessionAddress {
28450                    workspace_id: ws,
28451                    session: SessionKey {
28452                        instance_id: id,
28453                        generation: SessionGeneration(1),
28454                    },
28455                };
28456                shared.mark_spawn_provisional(address);
28457                id
28458            }));
28459        }
28460        let mut ids = Vec::new();
28461        for h in handles {
28462            ids.push(h.join().unwrap());
28463        }
28464        ids.sort_by_key(|id| id.0);
28465        assert_eq!(ids.len(), 20);
28466        ids.dedup();
28467        assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28468        assert_eq!(
28469            shared
28470                .provisional_spawns
28471                .lock()
28472                .unwrap_or_else(|p| p.into_inner())
28473                .len(),
28474            20
28475        );
28476        shared.sessionize_spawn(AgentInstanceId(7));
28477        let left: Vec<_> = shared
28478            .provisional_spawns
28479            .lock()
28480            .unwrap_or_else(|p| p.into_inner())
28481            .keys()
28482            .map(|id| id.0)
28483            .collect();
28484        assert!(!left.contains(&7));
28485        assert_eq!(left.len(), 19);
28486    }
28487}
28488
28489
28490#[cfg(unix)]
28491extern "C" {
28492    #[link_name = "geteuid"]
28493    fn libc_geteuid() -> u32;
28494}
28495
28496#[cfg(test)]
28497mod call_home_link_tests {
28498    use super::*;
28499
28500    #[test]
28501    fn call_home_backoff_is_capped_and_monotonic() {
28502        let mut last = Duration::ZERO;
28503        for attempt in 1..20 {
28504            let next = call_home_backoff(attempt);
28505            assert!(next >= last);
28506            assert!(next <= Duration::from_secs(8));
28507            last = next;
28508        }
28509        assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28510    }
28511
28512    #[tokio::test]
28513    async fn call_home_socket_gets_keepalive() {
28514        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28515        let addr = listener.local_addr().unwrap();
28516        let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28517        let client = client.unwrap();
28518        enable_call_home_keepalive(&client).unwrap();
28519        let sock = socket2::SockRef::from(&client);
28520        assert!(sock.keepalive().unwrap());
28521        #[cfg(any(target_os = "linux", target_os = "macos"))]
28522        assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28523    }
28524}
28525
28526/// A provider transcript for this session id exists in a known provider home.
28527fn provider_session_exists(provider: &str, id: &str) -> bool {
28528    if provider != "claude" { return false; }
28529    let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28530    if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28531    let name = format!("{id}.jsonl");
28532    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())))
28533}
28534
28535/// Append launch args to a prepared overlay (or create an instance overlay for them).
28536fn overlay_with_extra_args(
28537    provider: &AgentId,
28538    mode: SessionMode,
28539    overlay: Option<PreparedNativeLaunchOverlay>,
28540    args: Vec<OsString>,
28541) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28542    let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28543    Ok(match (overlay, args) {
28544        (prepared, args) if args.is_empty() => prepared,
28545        (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28546        (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28547        (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28548            provider.clone(),
28549            match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28550            Vec::new(),
28551            args,
28552        ).map_err(|_| bad())?)),
28553    })
28554}