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}
220
221/// `SpawnSessionOptions` -> g4a selection. Effort needs a model (g4a options hang off a model).
222fn spawn_selection(
223    options: &gate4agent_node_protocol::SpawnSessionOptions,
224) -> Result<Option<gate4agent_types::SessionOptionSelection>, NodeFailure> {
225    options.validate().map_err(|e| failure(NodeFailureCode::InvalidRequest, e))?;
226    match (&options.model, &options.effort) {
227        (None, None) => Ok(None),
228        (None, Some(_)) => Err(failure(NodeFailureCode::InvalidRequest, "spawn effort needs a model")),
229        (Some(model), effort) => {
230            let mut sel = gate4agent_types::SessionOptionSelection::new(model.clone());
231            if let Some(e) = effort {
232                sel = sel.with_value("effort", e.clone());
233            }
234            Ok(Some(sel))
235        }
236    }
237}
238
239mod browser_bridge;
240
241use gate4agent_node_core::drive_loop_profile;
242
243#[path = "bundle_delivery.rs"]
244mod bundle_delivery;
245
246#[path = "context_pack_store.rs"]
247mod context_pack_store;
248
249#[cfg(windows)]
250pub use crate::platform::DEFAULT_NODE_ENDPOINT;
251
252const NODE_EVENT_HISTORY_MAX: usize = 4_096;
253const NODE_BROADCAST_CAPACITY: usize = 1_024;
254const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
255/// Unlike `NODE_TERMINAL_BROADCAST_CAPACITY`'s single replaceable slot (a
256/// terminal frame is a full-screen snapshot; only the latest ever matters),
257/// an `AgentStreamChunkV1` is one delta in an ordered sequence -- collapsing
258/// two pending sends into one would silently drop content a subscriber can
259/// never recover. This needs headroom for a burst instead.
260const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
261/// The node never has more than `MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE`
262/// `NodeEvent::HarnessMcpReadCall`s in flight at once (`begin_call`'s own
263/// admission check), and `publish_transient` sends each exactly once as a
264/// single-envelope batch -- so this many slots covers the entire pending
265/// set without ever lagging the one subscriber that matters, the harness's
266/// own connection, the way sharing the durable `event_tx`'s bus could.
267const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
268const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
269const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
270const MAX_PREAUTH_CONNECTIONS: usize = 32;
271const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
272const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
273const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
274const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
275const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
276/// Fresh Spawn stays provisional until HQ successfully attaches (first Resize /
277/// TerminalBytes). This is an *attach-never-arrived* timeout from spawn time —
278/// not the disconnect reconnect grace (HQ owns that, starting only on a real
279/// client WS close). A provider conversation id is a session; a PTY with no
280/// attached HQ view is still a draft and is reaped here if attach never lands.
281const PROVISIONAL_SPAWN_GRACE_MS: u64 = 30_000;
282/// How often `drive_runtime_until_shutdown`'s loop re-evaluates the session
283/// record retention policy (`NodeServerConfig::session_record_retention`).
284/// A sweep cadence, not a policy value -- the policy itself lives entirely
285/// in the operator-configured age/keep-N flags; this just bounds how stale
286/// the in-memory view of dead `Unavailable` records can get between sweeps
287/// on an otherwise-idle node, mirroring the once-per-startup sweep that
288/// also runs alongside `reconcile_managed_worktrees`.
289const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
290/// TTL for `NodeShared::rejected_command_reasons` entries -- see that
291/// field's own doc for why a spawn waiter needs this at all. Must outlive
292/// the longest a waiter can still be polling for this exact command's
293/// outcome: `gate4agent_node_protocol::MAX_SPAWN_DEADLINE_MS` (120s) is the
294/// largest `deadline_ms` any caller may request, and the margin on top
295/// absorbs the waiter's own poll cadence plus clock skew between the drive
296/// loop (writer) and the waiter (reader).
297const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
298/// Bounds `NodeShared::rejected_command_reasons` against an unbounded
299/// backlog of asynchronously-rejected commands nobody ever collects (a
300/// waiter that stops polling, or a burst of always-rejected commands from a
301/// misbehaving caller): once at capacity, the oldest entry is evicted to
302/// make room for the newest, mirroring the eviction rule already used by
303/// `spawn_idempotency`.
304const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
305/// How long `wait_until_removed` waits between two `Remove` re-dispatches
306/// for the same session. The presence poll runs far faster (2ms); this
307/// only paces the sending, so an accepted `Remove` still in flight is not
308/// piled on by a dozen more that land on an already-dropped instance.
309const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
310const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
311const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
312const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
313const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
314const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
315const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
316const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
317const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
318const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
319const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
320const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
321const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
322/// Default total wall-clock budget for `inspect_workspace`'s walk+git
323/// chain, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS`.
324const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
325/// The harness relay enforces its own flat response deadline
326/// (`HOST_RUN_READ_RESPONSE_DEADLINE`, `hatchery-harness-service::runtime`,
327/// 12s) above this node-side budget. The two constants live in different
328/// crates with no shared definition, so clamp any override here comfortably
329/// below that external ceiling — a misconfigured value above it would
330/// reopen the exact deadline race this budget exists to close.
331const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
332/// Default cap on directory entries visited (not just returned) by one
333/// walk, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP`.
334const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
335const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
336const GIT_STATUS_MAX_ENTRIES: usize = 128;
337const GIT_COMMIT_MAX_ENTRIES: usize = 12;
338const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
339const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
340const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
341const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
342const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
343const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
344const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
345const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
346const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
347const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
348    "provider-session-identity-allocation-failed";
349const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
350const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
351const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
352const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
353const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
354const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
355const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
356    "durable-state-path-semantics-unsupported";
357
358fn opaque_windows_path(value: String) -> OpaqueHostPath {
359    OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
360}
361
362fn agent_progress_from_provider_snapshot(
363    address: SessionAddress,
364    provider: &ProviderSnapshot,
365) -> Option<SessionAgentProgress> {
366    let mut truncated = false;
367    let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
368    let mut active_tool_labels = Vec::with_capacity(
369        provider
370            .active_tools
371            .len()
372            .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
373    );
374    for tool in &provider.active_tools {
375        if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
376            truncated = true;
377            break;
378        }
379        if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
380            active_tool_labels.push(label);
381        }
382    }
383    let attention = provider
384        .interactions
385        .iter()
386        .find(|interaction| {
387            matches!(
388                interaction.status,
389                ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
390            )
391        })
392        .map(|interaction| AgentProgressAttentionV1 {
393            kind: match interaction.interaction_kind {
394                ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
395                ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
396            },
397            tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
398        });
399    let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
400    let usage = (provider.completed_turns > 0
401        || provider.usage.input_tokens > 0
402        || provider.usage.output_tokens > 0
403        || provider.usage.cache_read_tokens > 0
404        || provider.usage.cache_write_tokens > 0
405        || provider.usage.reasoning_tokens > 0)
406        .then_some(AgentProgressUsageV1 {
407            input_tokens: provider.usage.input_tokens,
408            output_tokens: provider.usage.output_tokens,
409            cache_read_tokens: provider.usage.cache_read_tokens,
410            cache_write_tokens: provider.usage.cache_write_tokens,
411            reasoning_tokens: provider.usage.reasoning_tokens,
412        });
413    let progress = AgentProgressV1 {
414        provider_sequence: provider.sequence,
415        activity: provider.activity,
416        completed_turns: provider.completed_turns,
417        usage,
418        current: AgentProgressCurrentV1::from(provider.activity),
419        active_tool_labels,
420        active_tool_count,
421        attention,
422        subagent_count,
423        last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
424        gap_count: provider.gap_count,
425        stale: provider.stale,
426        truncated,
427    };
428    let entry = SessionAgentProgress { address, progress };
429    serde_json::to_vec(&entry)
430        .ok()
431        .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
432        .map(|_| entry)
433}
434
435fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
436    match u32::try_from(count) {
437        Ok(count) => count,
438        Err(_) => {
439            *truncated = true;
440            u32::MAX
441        }
442    }
443}
444
445fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
446    if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
447        return None;
448    }
449    Some(match event {
450        ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
451        ProviderEvent::SessionIdentityObserved { .. } => {
452            AgentProgressEventKindV1::SessionIdentityObserved
453        }
454        ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
455        ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
456        ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
457        ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
458        ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
459        ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
460        ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
461        ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
462        ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
463        ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
464        ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
465        ProviderEvent::InteractionRequested { .. } => {
466            AgentProgressEventKindV1::InteractionRequested
467        }
468        ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
469        ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
470        ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
471        ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
472        ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
473        ProviderEvent::UnrecognizedNotification { .. } => {
474            AgentProgressEventKindV1::UnrecognizedNotification
475        }
476        ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
477        // ACP session/update coverage beyond text/tool/turn streaming
478        // (`plan`, `available_commands_update`, `current_mode_update`,
479        // `session_info_update`, `usage_update`, `config_option_update`,
480        // `user_message_chunk`). `AgentProgressEventKindV1` is a versioned
481        // wire enum without a `#[serde(other)]` fallback; minting new
482        // variants for it is a deliberate wire-contract decision for
483        // whoever owns this protocol, not a side effect of parsing more of
484        // ACP's own wire -- until that decision is made, these fold into
485        // the same "we saw something, no dedicated kind for it yet" bucket
486        // as `UnrecognizedNotification`.
487        ProviderEvent::UserMessage { .. }
488        | ProviderEvent::Plan { .. }
489        | ProviderEvent::AvailableCommandsUpdated { .. }
490        | ProviderEvent::ModeChanged { .. }
491        | ProviderEvent::SessionInfoUpdated { .. }
492        | ProviderEvent::UsageUpdated { .. }
493        | ProviderEvent::ConfigOptionsUpdated { .. } => {
494            AgentProgressEventKindV1::UnrecognizedNotification
495        }
496    })
497}
498
499/// Maps this harness's own `HostDecisionAuthority` onto the wider
500/// Maps a host request decider onto the `BlockAuthorityV1` a `Blocked` stream
501/// chunk carries -- the four authorities a `Denied` decision is ever made
502/// under, one-for-one. The remaining `BlockAuthorityV1` variants
503/// (`ProviderClassifier`, `ProviderPermissionRule`, `ProviderSandbox`,
504/// `ProviderRefusal`, `UserRejected`, `ProviderQuota`, `Unknown`) describe a
505/// block the PROVIDER side of the wire reported -- this function never
506/// produces one, because `ProviderHostDecisionAuthority` has no vocabulary for
507/// them; the provider-side blocks below mint those directly from typed fields.
508/// Whether a provider source is a live session transport -- the only kind
509/// whose events reach the agent stream. History, resume, session-option and
510/// capability-probe sources are not sessions and stream nothing.
511fn provider_family_is_live(family: AdapterFamily) -> bool {
512    match family {
513        AdapterFamily::PtySemantic
514        | AdapterFamily::Hook
515        | AdapterFamily::ManagedHook
516        | AdapterFamily::Pipe
517        | AdapterFamily::OneShot
518        | AdapterFamily::Acp => true,
519        AdapterFamily::History
520        | AdapterFamily::Resume
521        | AdapterFamily::SessionOptions
522        | AdapterFamily::CapabilityProbe => false,
523    }
524}
525
526fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
527    match by {
528        ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
529        ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
530        ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
531        ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
532    }
533}
534
535/// The `Blocked::reason` for a `Denied` decision this build's own
536/// `AgentEvent::RpcIncomingRequest::reason` did not attach text to --
537/// reachable today for `Operator`/`HostDeadline`: a deferred `session/
538/// request_permission` request resolved by `AcpSession::
539/// resolve_pending_request`/`expire_deadlines` (`src/acp/session.rs`),
540/// neither of which computes free text (an operator answers approve/reject,
541/// never a comment; an unattended deadline asks nobody anything). `reason`
542/// is a REQUIRED field on the wire (see `AgentStreamChunkKindV1::Blocked`)
543/// -- a categorical fallback naming the authority is the
544/// honest minimum, never a placeholder standing in for detail nobody
545/// reported.
546fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
547    match authority {
548        BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
549        BlockAuthorityV1::HostPolicy => "denied by host policy",
550        BlockAuthorityV1::HostDeadline => {
551            "denied by host policy after the operator deadline expired"
552        }
553        BlockAuthorityV1::Operator => "declined by the operator",
554        BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
555        BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
556        BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
557        BlockAuthorityV1::ProviderRefusal => "refused by the provider",
558        BlockAuthorityV1::UserRejected => "rejected by the user",
559        BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
560        BlockAuthorityV1::Unknown => "blocked; no reason was reported",
561    }
562}
563
564/// Computes `AgentStreamChunkKindV1::Blocked`'s `tool_class`/`authority`/`reason`
565/// for a `HostRequestObserved` `Denied` decision.
566fn host_request_denied_block(
567    method: &str,
568    by: ProviderHostDecisionAuthority,
569    reason: Option<&str>,
570) -> (String, BlockAuthorityV1, String) {
571    let authority = block_authority(by);
572    let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
573    // `AgentStreamChunkKindV1::validate` REJECTS an over-long reason outright
574    // rather than truncating -- this cut happens here, at the producer, before
575    // the validator ever sees the text.
576    let (reason_text, _) =
577        truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
578    (tool_class_label(method), authority, reason_text)
579}
580
581// ---------------------------------------------------------------------------
582// K2 -- provider-side blocks read off ACP `tool_call_update._meta.
583// nonExecutionKind` (`ProviderEvent::ToolCompleted::non_execution_kind`) and
584// `SessionEnded::stop_reason` (`stopReason: "refusal"`, or a `session/
585// prompt` RPC error whose vendor code names a quota/limit/rate
586// exhaustion). See
587// `docs/gate4agent/plans/gate4agent-blocked-action-event-2026-09-02.md`
588// §3 slice K2.
589// ---------------------------------------------------------------------------
590
591/// Classifies ACP's `nonExecutionKind` into the typed authority a block
592/// this build mints under, or `None` when the tool truly did not run for a
593/// reason that is NOT a block -- `"interrupted"`/`"cancelled"` mean the
594/// TURN stopped (session closed, the operator cancelled), not that a
595/// policy/classifier/rule refused the call, so those (and any unrecognized
596/// future value) stay a plain `ToolCompleted { success: false }` only, per
597/// this plan's own rule that a tool failure is never inflated into a block
598/// this build cannot actually attribute.
599fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
600    match kind? {
601        "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
602        "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
603        _ => None,
604    }
605}
606
607/// Splits a provider's own denial sentence into `reason`/`help`, when it
608/// matches the fixed shape Claude Code's classifier/permission-rule denial
609/// uses -- verified live, the owner's sample fixture
610/// (`docs/gate4agent/research/gate4agent-blocked-action-signals-2026-09-02.md`
611/// appendix): "...denied by <authority>. Reason: <reason>. <help...>".
612/// `authority` is NEVER derived from this text (see this module's own rule
613/// that authority comes only from a typed field) -- only `reason`/`help`
614/// are. Falls back to the full text as `reason` with no `help` when the
615/// shape does not match (a provider whose text does not carry this exact
616/// wording) -- the honest minimum: the tool truly did not run, so SOME
617/// reason is always reported, never fabricated detail beyond what arrived.
618fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
619    let trimmed = text.trim();
620    let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
621        return (trimmed.to_owned(), None);
622    };
623    let tail = after_reason.trim_start();
624    let Some((reason, help)) = tail.split_once(". ") else {
625        // "Reason:" present but no ". " to split help off -- the whole
626        // tail is the reason, e.g. "Reason: Blocked by classifier." with
627        // no help text following on some providers/versions.
628        let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
629        return (reason.to_owned(), None);
630    };
631    let help = help.trim();
632    if help.is_empty() {
633        (reason.trim().to_owned(), None)
634    } else {
635        (reason.trim().to_owned(), Some(help.to_owned()))
636    }
637}
638
639/// `tool_class` for a provider-side `ActionBlocked` this build has no typed
640/// tool name for -- `ProviderEvent::ToolCompleted` carries only `id`, never
641/// `name` (see `ProviderEvent::ToolStarted`, which does, but a tool's own
642/// name is never threaded through to its completion). Reads the class off
643/// the guidance tail's own "add a <Class> permission rule" sentence when
644/// present (the shape the owner's sample fixture uses) -- text matching
645/// fills `tool_class` here, same as `reason`/`help`, NEVER `authority` (see
646/// this module's own rule). Falls back to the same generic `"Tool"` class
647/// `ToolCompleted`'s own observation already uses when nothing names one.
648fn tool_class_from_denial_help(help: Option<&str>) -> String {
649    help.and_then(|text| text.split("add a ").nth(1))
650        .and_then(|tail| tail.split(' ').next())
651        .filter(|word| !word.is_empty())
652        .map(str::to_owned)
653        .unwrap_or_else(|| "Tool".to_owned())
654}
655
656/// Whether a `session/prompt` RPC error's vendor code (or, absent one, its
657/// message) names a quota/rate-limit exhaustion -- Codex's
658/// `data.codexErrorInfo: "usageLimitExceeded"` (measured live 2026-09-05)
659/// contains "limit", the word this build actually keys on; "quota"/"rate"
660/// are kept for whichever OTHER vendor's own code names one of those
661/// instead, never confirmed live, but the honest generalization of the
662/// one confirmed sample.
663fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
664    fn names_quota(s: &str) -> bool {
665        let lower = s.to_ascii_lowercase();
666        lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
667    }
668    vendor_code.is_some_and(names_quota) || names_quota(message)
669}
670
671/// The `Blocked` chunk this build mints when a turn stopped via a
672/// typed provider fact rather than a tool call -- `stopReason: "refusal"`
673/// (`authority: ProviderRefusal`) or a `session/prompt` RPC error whose
674/// vendor code (or message) names a quota/limit/rate exhaustion
675/// (`authority: ProviderQuota`, `reason` = the error message verbatim,
676/// `reason_kind` = the vendor code). `None` for every other stop --
677/// `end_turn`, `max_tokens`, `max_turn_requests`, `cancelled`, an untagged
678/// `Other`, or an RPC error whose vendor code/message names none of the
679/// three words: `SessionEnded` stays a plain `Exited`/turn-end fact for
680/// those, never inflated into a block this build cannot actually
681/// attribute. `ProviderRefusal`'s `reason` has no source text to read on
682/// `SessionEnded` itself (ACP's `refusal` stopReason carries none) -- the
683/// honest minimum names the fact that happened, never invented detail; a
684/// later slice that threads the turn's last agent text chunk through can
685/// replace it without changing this function's contract.
686fn provider_session_end_block(
687    stop_reason: &ProviderStopReason,
688) -> Option<(BlockAuthorityV1, String, String)> {
689    match stop_reason {
690        ProviderStopReason::Refusal => Some((
691            BlockAuthorityV1::ProviderRefusal,
692            "refusal".to_owned(),
693            "the provider ended the turn with stopReason: refusal".to_owned(),
694        )),
695        ProviderStopReason::ProviderError { message, vendor_code, .. }
696            if provider_error_is_quota(vendor_code.as_deref(), message) =>
697        {
698            let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
699            Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
700        }
701        _ => None,
702    }
703}
704
705/// Builds the `AgentStreamChunkV1` `event`'s `ProviderEvent` carries onto the
706/// `agent-stream-events-v1` channel -- see
707/// `docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
708/// §5. Two invariants keep the chunk correlatable with the `NodeEvent::Control`
709/// event it derives from, so a client joining the two sees one id and one
710/// ordering:
711///
712/// - `source_sequence` on the chunk is the `source_sequence` the provider
713///   event itself carries.
714/// - `InteractionPrompt::correlation_id` is minted by
715///   `correlation::interaction_correlation` with the
716///   `(instance_id, generation, provider_sequence)` triple -- never the
717///   provider's own `request_id`, whose opacity on the wire is pinned by a
718///   dedicated test.
719///
720/// Returns `None` for every `ControlEventKind` other than `ProviderEvent`
721/// (nothing else carries stream content), for a source that is not a live
722/// session transport, and for the `ProviderEvent` variants below with no
723/// declared chunk kind to carry them, or whose declared chunk kind this build
724/// has no source for yet:
725///
726/// - `ModelCatalog { current, available }` has no `ProviderEvent` source at
727///   all -- grok's vendor model switch is not bridged into `ProviderEvent`
728///   yet, the same gap the plan names explicitly.
729/// - `UserMessage { text, is_delta }` is an echo of the OPERATOR's own
730///   prompt, replayed on session resume -- `Text`/`Thinking` are documented
731///   as "what the agent is saying", so mapping the operator's words onto
732///   `Text` would misrepresent whose content it is. No dedicated
733///   user-message kind is declared, so this stays unproduced rather than
734///   mislabeled.
735///
736/// `HostRequestObserved { .. }` is its own arm below: a `Granted`/`Deferred`
737/// decision returns `None` (nothing for the operator to react to), a `Denied`
738/// decision returns `Some(Blocked { .. })`.
739///
740/// `ToolCompleted { .. }`/`SessionEnded { .. }` are likewise their own arms
741/// below (K2): each returns `None` unless
742/// `provider_tool_block_authority`/`provider_session_end_block` classifies the
743/// event as an actual block (ACP's `_meta.nonExecutionKind`, or `stopReason`/an
744/// RPC error naming a quota).
745fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
746    let ControlEventKind::ProviderEvent {
747        sequence: provider_sequence,
748        source,
749        source_sequence,
750        event: provider_event,
751    } = &event.event
752    else {
753        return None;
754    };
755    if !provider_family_is_live(source.family) {
756        return None;
757    }
758    let kind = match provider_event {
759        ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
760            text: text.clone(),
761            is_delta: *is_delta,
762        },
763        ProviderEvent::Thinking { text } => {
764            AgentStreamChunkKindV1::Thinking { text: text.clone() }
765        }
766        ProviderEvent::InteractionRequested {
767            interaction_kind,
768            tool_name,
769            title,
770            prompt,
771            options,
772            ..
773        } => AgentStreamChunkKindV1::InteractionPrompt {
774            correlation_id: interaction_correlation(
775                event.instance_id,
776                event.generation,
777                *provider_sequence,
778            ),
779            interaction_kind: *interaction_kind,
780            tool_name: tool_name.clone(),
781            // `title`/`options` are carried straight through from
782            // `ProviderEvent::InteractionRequested`, as the agent itself
783            // gave them on `session/request_permission`'s `PermissionTool
784            // Call.title`/`options` (see `gate4agent-shell-native`'s
785            // `provider_event`, the only ACP-transport producer). A PTY- or
786            // hook-sourced interaction carries `title: None` and
787            // `options: []` from its own producer because that source has
788            // none to offer, never because this mapping dropped one.
789            title: title.clone(),
790            prompt: prompt.clone(),
791            options: options
792                .iter()
793                .map(|option| AgentStreamInteractionOptionV1 {
794                    option_id: option.option_id.clone(),
795                    name: option.name.clone(),
796                    kind: option.kind.clone(),
797                })
798                .collect(),
799        },
800        ProviderEvent::ConfigOptionsUpdated { options } => {
801            AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
802        }
803        // `available` is the mode catalogue `AcpSession::available_modes()`
804        // read back at `session/new` and threaded through by
805        // `gate4agent-shell-native`'s `provider_event` (see that crate's
806        // `ProviderEvent::ModeChanged` arm) -- an empty list here is the
807        // agent's own honest "no modes announced", never "not read": ACP
808        // orders `session/new` before any `session/update`, and the
809        // catalogue is fixed at handshake, so a `ModeChanged` event cannot
810        // exist before the same session object's catalogue is already
811        // populated (see `gate4agent_types::ProviderEvent::ModeChanged`'s
812        // own doc comment for the ordering argument in full).
813        ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
814            current: Some(mode_id.clone()),
815            available: available
816                .iter()
817                .map(|mode| AgentStreamNamedIdV1 {
818                    id: mode.id.clone(),
819                    name: mode.name.clone(),
820                    description: mode.description.clone(),
821                })
822                .collect(),
823        },
824        // A denied host request is a block -- see this function's own doc
825        // comment for why `HostRequestObserved` is not in the exclusion list
826        // below.
827        ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
828            let ProviderHostRequestDecision::Denied { by } = decision else {
829                return None;
830            };
831            let (tool_class, authority, reason_text) =
832                host_request_denied_block(method, *by, reason.as_deref());
833            AgentStreamChunkKindV1::Blocked {
834                correlation_id: None,
835                tool_class,
836                authority,
837                reason_kind: None,
838                reason: reason_text,
839                help: None,
840            }
841        }
842        // Same relationship as `HostRequestObserved` above, driven by ACP's
843        // own `_meta.nonExecutionKind` instead of the host's
844        // `HostRequestDecision`.
845        ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
846            let (authority, reason_kind) =
847                provider_tool_block_authority(non_execution_kind.as_deref())?;
848            let (reason, help) = split_provider_denial_text(output);
849            let (reason, _) =
850                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
851            let help = help.map(|help| {
852                truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
853            });
854            AgentStreamChunkKindV1::Blocked {
855                correlation_id: Some(tool_correlation(event, source, id)),
856                tool_class: tool_class_from_denial_help(help.as_deref()),
857                authority,
858                reason_kind: Some(reason_kind.to_owned()),
859                reason,
860                help,
861            }
862        }
863        // Same relationship, driven by `stopReason: "refusal"` or a
864        // `session/prompt` RPC error naming a quota/limit/rate exhaustion
865        // instead of a tool-level fact.
866        ProviderEvent::SessionEnded { stop_reason, .. } => {
867            let (authority, reason_kind, reason) =
868                stop_reason.as_ref().and_then(provider_session_end_block)?;
869            let (reason, _) =
870                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
871            AgentStreamChunkKindV1::Blocked {
872                correlation_id: None,
873                tool_class: "Session".to_owned(),
874                authority,
875                reason_kind: Some(reason_kind),
876                reason,
877                help: None,
878            }
879        }
880        ProviderEvent::SessionStarted { .. }
881        | ProviderEvent::SessionIdentityObserved { .. }
882        | ProviderEvent::TurnStarted { .. }
883        | ProviderEvent::WorkingObserved
884        | ProviderEvent::ToolStarted { .. }
885        | ProviderEvent::TurnCompleted { .. }
886        | ProviderEvent::ContextWindowUsage { .. }
887        | ProviderEvent::TurnInterrupted
888        | ProviderEvent::Error { .. }
889        | ProviderEvent::Ready
890        | ProviderEvent::InteractionResolved { .. }
891        | ProviderEvent::SubagentStarted { .. }
892        | ProviderEvent::SubagentStopped { .. }
893        | ProviderEvent::RateLimited { .. }
894        | ProviderEvent::UnrecognizedNotification { .. }
895        | ProviderEvent::UserMessage { .. }
896        | ProviderEvent::Plan { .. }
897        | ProviderEvent::AvailableCommandsUpdated { .. }
898        | ProviderEvent::SessionInfoUpdated { .. }
899        | ProviderEvent::UsageUpdated { .. } => return None,
900    };
901    Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
902}
903
904async fn run_native_session_catalog_operation<T, E, F>(
905    catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
906    operation: &'static str,
907    execute: F,
908) -> Result<T, NodeFailure>
909where
910    T: Send + 'static,
911    E: std::fmt::Display + Send + 'static,
912    F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
913{
914    let task = tokio::task::spawn_blocking(move || {
915        let mut catalog = match catalog.try_lock() {
916            Ok(catalog) => catalog,
917            Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
918            Err(std::sync::TryLockError::WouldBlock) => {
919                return Err(failure(
920                    NodeFailureCode::BackendBusy,
921                    "native session catalog operation is already running",
922                ));
923            }
924        };
925        execute(&mut catalog).map_err(|error| {
926            failure(
927                NodeFailureCode::BackendOperationFailed,
928                &format!("{operation} failed: {error}"),
929            )
930        })
931    });
932    timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
933        .await
934        .map_err(|_| {
935            failure(
936                NodeFailureCode::BackendBusy,
937                &format!("{operation} exceeded its bounded deadline"),
938            )
939        })?
940        .map_err(|_| {
941            failure(
942                NodeFailureCode::BackendOperationFailed,
943                &format!("{operation} task failed"),
944            )
945        })?
946}
947
948fn windows_path_text(path: &OpaqueHostPath) -> &str {
949    path.as_utf8()
950        .expect("Windows node only creates UTF-8 opaque host paths")
951}
952
953fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
954    path.as_utf8()
955        .map(str::to_owned)
956        .ok_or_else(|| failure(
957            NodeFailureCode::InvalidRequest,
958            "this node requires UTF-8 host paths",
959        ))
960}
961
962fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
963    GitWorktreeSnapshot {
964        path: opaque_windows_path(worktree.path),
965        head: worktree.head,
966        branch: worktree.branch,
967        is_bare: worktree.is_bare,
968        is_main: worktree.is_main,
969        locked: worktree.locked,
970        lock_reason: worktree.lock_reason,
971        prunable: worktree.prunable,
972        prunable_reason: worktree.prunable_reason,
973        workspace_id: worktree.workspace_id.take(),
974    }
975}
976
977#[derive(Clone, Debug, Eq, PartialEq)]
978pub struct WorkspaceConfig {
979    workspace_id: WorkspaceId,
980    canonical_root: String,
981    worktree_service_mode: WorktreeServiceMode,
982    managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
983}
984
985impl WorkspaceConfig {
986    pub fn new(
987        workspace_id: WorkspaceId,
988        root: impl AsRef<Path>,
989    ) -> Result<Self, NodeServerError> {
990        let root = root.as_ref();
991        if !root.is_absolute() {
992            return Err(NodeServerError::InvalidWorkspaceRoot {
993                workspace_id,
994                path: root.to_string_lossy().into_owned(),
995                message: "path is not absolute".to_owned(),
996            });
997        }
998        let canonical = std::fs::canonicalize(root).map_err(|error| {
999            NodeServerError::InvalidWorkspaceRoot {
1000                workspace_id: workspace_id.clone(),
1001                path: root.to_string_lossy().into_owned(),
1002                message: error.to_string(),
1003            }
1004        })?;
1005        if !canonical.is_dir() {
1006            return Err(NodeServerError::InvalidWorkspaceRoot {
1007                workspace_id,
1008                path: root.to_string_lossy().into_owned(),
1009                message: "path is not a directory".to_owned(),
1010            });
1011        }
1012        let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
1013            NodeServerError::InvalidWorkspaceRoot {
1014                workspace_id: workspace_id.clone(),
1015                path: path.to_string_lossy().into_owned(),
1016                message: "canonical path is not valid Unicode".to_owned(),
1017            }
1018        })?;
1019        let canonical_root = platform::normalize_canonical_root(canonical_root);
1020        validate_workspace_root(&workspace_id, &canonical_root)?;
1021        Ok(Self {
1022            workspace_id,
1023            canonical_root,
1024            worktree_service_mode: WorktreeServiceMode::Manual,
1025            managed_worktree_profiles: BTreeMap::new(),
1026        })
1027    }
1028
1029    pub fn workspace_id(&self) -> &WorkspaceId {
1030        &self.workspace_id
1031    }
1032
1033    pub fn canonical_root(&self) -> &str {
1034        &self.canonical_root
1035    }
1036
1037    pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1038        self.worktree_service_mode = mode;
1039        self
1040    }
1041
1042    pub fn with_managed_worktree_profile(
1043        mut self,
1044        profile: ManagedWorktreeProfile,
1045    ) -> Result<Self, NodeServerError> {
1046        profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1047            NodeServerError::InvalidManagedWorktreeProfile {
1048                workspace_id: self.workspace_id.clone(),
1049                profile_id: profile.profile_id().clone(),
1050                message,
1051            }
1052        })?;
1053        if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1054            return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1055                workspace_id: self.workspace_id,
1056                profile_id: profile.profile_id().clone(),
1057            });
1058        }
1059        if self.managed_worktree_profiles.len()
1060            == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1061        {
1062            return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1063                workspace_id: self.workspace_id,
1064                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1065            });
1066        }
1067        self.managed_worktree_profiles
1068            .insert(profile.profile_id().clone(), profile);
1069        Ok(self)
1070    }
1071}
1072
1073#[derive(Clone)]
1074pub struct NodeServerConfig {
1075    pub endpoint: String,
1076    api_listen: Option<std::net::SocketAddr>,
1077    /// Opt-in node-envelope HTTP+WS browser bridge (separate from `--api-listen`
1078    /// ops HTTP and from framed C2). Loopback only; default `None` = off.
1079    bridge_listen: Option<std::net::SocketAddr>,
1080    /// Optional bridge shared secret (`GATE4AGENT_BRIDGE_TOKEN`). Distinct from
1081    /// NODE_TOKEN; never logged. `None` = loopback bind is the only gate.
1082    bridge_token: Option<String>,
1083    /// Tip 6: optional mesh underlay UDP bind that relays to local `--bridge-listen`.
1084    /// Requires bridge_listen + bridge_token. HQ never accepts (NodePeer).
1085    bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1086    /// A relay to CALL, rather than wait to be called by. `None` keeps the
1087    /// node purely a listener, which is what it has always been.
1088    call_home: Option<std::net::SocketAddr>,
1089    /// When set, call-home authenticates by signing the relay's challenge with this
1090    /// identity (enrolled node) and falls back to the token handshake against a relay
1091    /// that sends no challenge.
1092    call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1093    pub node_id: NodeId,
1094    pub workspaces: Vec<WorkspaceConfig>,
1095    access_token: String,
1096    pub runtime: NativeRuntimeConfig,
1097    session_record_retention: SessionRecordRetentionConfig,
1098    state_path: Option<PathBuf>,
1099    spawn_profiles: SpawnProfileRegistry,
1100    session_environment: Option<NodeSessionEnvironmentConfig>,
1101    history: Option<NativeHistoryConfig>,
1102    harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1103    /// Station network allowlist catalog (opaque ids). Empty default.
1104    /// Loaded at startup from `--network-allowlist-catalog` / env
1105    /// `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG` file. Never secrets.
1106    network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1107    #[cfg(feature = "fixture")]
1108    fixture_raw_pty_runtime: bool,
1109}
1110
1111#[derive(Clone)]
1112struct NodeSessionEnvironmentConfig {
1113    root: PathBuf,
1114    resolver: Arc<dyn NodeSecretResolver>,
1115}
1116
1117impl NodeServerConfig {
1118    pub fn new(
1119        endpoint: impl Into<String>,
1120        access_token: impl Into<String>,
1121        node_id: NodeId,
1122        workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1123    ) -> Result<Self, NodeServerError> {
1124        let endpoint = endpoint.into();
1125        let access_token = access_token.into();
1126        if !platform::validate_endpoint(&endpoint) {
1127            return Err(NodeServerError::InvalidEndpoint);
1128        }
1129        if access_token.is_empty() || access_token.len() > 4_096 {
1130            return Err(NodeServerError::InvalidAccessToken);
1131        }
1132        let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1133        if workspaces.is_empty() {
1134            return Err(NodeServerError::NoWorkspaces);
1135        }
1136        let mut workspace_ids = BTreeMap::new();
1137        let mut workspace_roots = BTreeMap::new();
1138        for workspace in &workspaces {
1139            if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1140                && workspace.managed_worktree_profiles.is_empty()
1141            {
1142                return Err(NodeServerError::ManagedWorktreeProfileRequired(
1143                    workspace.workspace_id.clone(),
1144                ));
1145            }
1146            if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1147                && !workspace.managed_worktree_profiles.is_empty()
1148            {
1149                return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1150                    workspace.workspace_id.clone(),
1151                ));
1152            }
1153            if workspace_ids
1154                .insert(workspace.workspace_id.clone(), ())
1155                .is_some()
1156            {
1157                return Err(NodeServerError::DuplicateWorkspaceId(
1158                    workspace.workspace_id.clone(),
1159                ));
1160            }
1161            let root_key = platform::root_identity(&workspace.canonical_root);
1162            if let Some(existing) = workspace_roots.insert(
1163                root_key,
1164                workspace.workspace_id.clone(),
1165            ) {
1166                return Err(NodeServerError::DuplicateWorkspaceRoot {
1167                    first: existing,
1168                    second: workspace.workspace_id.clone(),
1169                    root: workspace.canonical_root.clone(),
1170                });
1171            }
1172        }
1173        Ok(Self {
1174            endpoint,
1175            api_listen: None,
1176            bridge_listen: None,
1177            bridge_token: None,
1178            bridge_underlay: None,
1179            call_home: None,
1180            call_home_identity: None,
1181            node_id,
1182            workspaces,
1183            access_token,
1184            // PTY output is polled by the runtime's effect worker; the upstream
1185            // default (20ms) added ~10-20ms to every keystroke echo.
1186            runtime: NativeRuntimeConfig {
1187                worker_poll_interval_ms: 4,
1188                ..NativeRuntimeConfig::default()
1189            },
1190            session_record_retention: SessionRecordRetentionConfig::default(),
1191            state_path: None,
1192            spawn_profiles: SpawnProfileRegistry::default(),
1193            session_environment: None,
1194            history: None,
1195            harness_mcp_helper: None,
1196            network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1197            #[cfg(feature = "fixture")]
1198            fixture_raw_pty_runtime: false,
1199        })
1200    }
1201
1202    pub fn with_api_listen(
1203        mut self,
1204        api_listen: std::net::SocketAddr,
1205    ) -> Result<Self, NodeServerError> {
1206        if !api_listen.ip().is_loopback() {
1207            return Err(NodeServerError::InvalidApiListen(api_listen));
1208        }
1209        self.api_listen = Some(api_listen);
1210        Ok(self)
1211    }
1212
1213    /// Opt-in node-envelope HTTP+WS bridge listener. Loopback only (same spirit
1214    /// as `with_api_listen`). Does not enable by default — libraries and the
1215    /// binary leave this `None` until `--bridge-listen` / `with_bridge_listen`.
1216    pub fn with_bridge_listen(
1217        mut self,
1218        bridge_listen: std::net::SocketAddr,
1219    ) -> Result<Self, NodeServerError> {
1220        if !bridge_listen.ip().is_loopback() {
1221            return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1222        }
1223        self.bridge_listen = Some(bridge_listen);
1224        Ok(self)
1225    }
1226
1227    /// Optional bridge shared secret, distinct from the C2/node access token.
1228    /// Never log the value. Empty / oversize refused.
1229    pub fn with_bridge_token(
1230        mut self,
1231        bridge_token: impl Into<String>,
1232    ) -> Result<Self, NodeServerError> {
1233        let bridge_token = bridge_token.into();
1234        if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1235            return Err(NodeServerError::InvalidBridgeToken);
1236        }
1237        self.bridge_token = Some(bridge_token);
1238        Ok(self)
1239    }
1240
1241    /// Tip 6: point bridge reachability over mesh underlay (UDP+AEAD relay to
1242    /// local loopback `--bridge-listen`). Requires bridge listen + bridge token
1243    /// already configured. Transport key is 32 raw bytes (never logged).
1244    /// Underlay auth token is distinct from BRIDGE_TOKEN (crypto ≠ auth).
1245    pub fn with_bridge_underlay(
1246        mut self,
1247        bind: std::net::SocketAddr,
1248        transport_key: [u8; 32],
1249        underlay_token: impl Into<String>,
1250    ) -> Result<Self, NodeServerError> {
1251        if self.bridge_listen.is_none() {
1252            return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1253        }
1254        if self.bridge_token.is_none() {
1255            return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1256        }
1257        let underlay_token = underlay_token.into();
1258        if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1259            return Err(NodeServerError::InvalidBridgeUnderlayToken);
1260        }
1261        // Port 0 is allowed (ephemeral). Role lock (no HQ accept) lives in underlay.
1262        self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1263            bind,
1264            transport_key,
1265            underlay_token,
1266        });
1267        Ok(self)
1268    }
1269
1270    /// Also dial `relay` and serve this wire over the connection, in
1271    /// addition to accepting on the local endpoint.
1272    ///
1273    /// Who opens the socket and who is the protocol's server are separate
1274    /// questions, and this is the node's half of separating them. The node
1275    /// stays the server either way -- it answers `NodeRequest`s and emits
1276    /// `NodeEvent`s exactly as it does for a relay that dialled it. All
1277    /// that changes is that a node with no address anyone can reach makes
1278    /// the connection itself, and names itself once it arrives (see
1279    /// `gate4agent_node_wire::write_call_home_announce`).
1280    ///
1281    /// Loopback only, and that is not an oversight to be flagged and
1282    /// worked around. This wire carries terminal contents, keystrokes and
1283    /// file bytes as plaintext JSON; its mutual challenge-response
1284    /// authenticates both ends and encrypts nothing. Every listener in
1285    /// this stack is loopback-guarded for that reason, and a dialler must
1286    /// hold the same line -- a node calling a public relay would put the
1287    /// whole control plane on the wire in the clear. Reaching a relay on
1288    /// another host is a transport question, and it stays unanswered here
1289    /// on purpose: this change is about direction, not distance.
1290    pub fn with_call_home(
1291        mut self,
1292        relay: std::net::SocketAddr,
1293    ) -> Result<Self, NodeServerError> {
1294        // Loopback, or the concrete C2 WireGuard tunnel address. Unspecified
1295        // and port 0 refused — plaintext wire must not dial every interface.
1296        if relay.port() == 0 || relay.ip().is_unspecified() {
1297            return Err(NodeServerError::InvalidCallHome(relay));
1298        }
1299        self.call_home = Some(relay);
1300        Ok(self)
1301    }
1302
1303    /// Authenticate call-home with a node-held identity instead of a standing token.
1304    pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1305        self.call_home_identity = Some(identity);
1306        self
1307    }
1308
1309    pub fn with_state_path(
1310        mut self,
1311        state_path: impl Into<PathBuf>,
1312    ) -> Result<Self, NodeServerError> {
1313        let state_path = state_path.into();
1314        if !state_path.is_absolute() || state_path.file_name().is_none() {
1315            return Err(NodeServerError::InvalidStatePath(
1316                state_path.to_string_lossy().into_owned(),
1317            ));
1318        }
1319        self.state_path = Some(state_path);
1320        Ok(self)
1321    }
1322
1323    pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1324        self.spawn_profiles = spawn_profiles;
1325        self
1326    }
1327
1328    /// Sets the age/keep-N policy the drive loop's retention sweep uses to
1329    /// retire dead `ManagedSessionState::Unavailable` records. Both fields
1330    /// default to `0` (disabled) via `SessionRecordRetentionConfig::default`
1331    /// when this is never called -- a node never deletes a durable record
1332    /// until an operator opts in with a real value.
1333    pub fn with_session_record_retention(
1334        mut self,
1335        retention: SessionRecordRetentionConfig,
1336    ) -> Self {
1337        self.session_record_retention = retention;
1338        self
1339    }
1340
1341    pub fn with_session_environment_materialization(
1342        mut self,
1343        root: impl Into<PathBuf>,
1344        resolver: Arc<dyn NodeSecretResolver>,
1345    ) -> Result<Self, NodeServerError> {
1346        let root = root.into();
1347        if !root.is_absolute() {
1348            return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1349        }
1350        self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1351        Ok(self)
1352    }
1353
1354    /// Enables bounded provider-history discovery and loading from explicit
1355    /// host-owned roots. Ambient vendor homes are never inferred here.
1356    pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1357        self.history = Some(history);
1358        self
1359    }
1360
1361    /// Enables the H3B read proxy with one exact operator-reviewed helper file.
1362    /// Linux/macOS use owner-only UDS; Windows uses owner-only named pipes.
1363    /// Both platforms review the helper the same way (absolute regular file +
1364    /// identity hash) — the old `cfg(not(windows))` hard-refuse was a leftover
1365    /// gate after Unix listener/peer-cred support landed.
1366    pub fn with_harness_mcp_helper(
1367        mut self,
1368        helper_program: impl Into<PathBuf>,
1369    ) -> Result<Self, NodeServerError> {
1370        self.harness_mcp_helper = Some(
1371            ReviewedHarnessMcpProgram::review(helper_program.into())
1372                .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1373        );
1374        Ok(self)
1375    }
1376
1377    /// Install an in-memory station network allowlist catalog (opaque ids +
1378    /// optional node-local permits / provider-native mapping). Empty remains
1379    /// the default — unknown spawn ids refuse at resolve. Never cookies /
1380    /// OAuth / proxy credentials. C2 / inventory still list ids only.
1381    pub fn with_network_allowlist_catalog(
1382        mut self,
1383        catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1384    ) -> Result<Self, NodeServerError> {
1385        if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1386            return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1387                max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1388            });
1389        }
1390        self.network_allowlist_catalog = catalog;
1391        Ok(self)
1392    }
1393
1394    /// Load station network allowlist catalog from an absolute regular file
1395    /// (v1 id-list or v2 JSON schema). Unset path is not used here — callers
1396    /// that want env fallback use `resolve_network_allowlist_catalog`.
1397    pub fn with_network_allowlist_catalog_file(
1398        self,
1399        path: impl AsRef<std::path::Path>,
1400    ) -> Result<Self, NodeServerError> {
1401        let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1402            .map_err(NodeServerError::from)?;
1403        self.with_network_allowlist_catalog(catalog)
1404    }
1405}
1406
1407pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1408    platform::default_state_path(node_id.as_str())
1409        .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1410}
1411
1412pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1413    platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1414}
1415
1416fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1417    let parent = state_path.parent()?;
1418    let mut name = state_path.file_name()?.to_os_string();
1419    name.push(".delivery-store");
1420    Some(parent.join(name))
1421}
1422
1423fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1424    let parent = state_path.parent()?;
1425    let mut name = state_path.file_name()?.to_os_string();
1426    name.push(".context-pack-store");
1427    Some(parent.join(name))
1428}
1429
1430pub struct NodeServer {
1431    config: NodeServerConfig,
1432    runtime: NativeRuntime,
1433    shared: Arc<NodeShared>,
1434    events: EventSubscription,
1435    state_path_lock: Option<session_registry::StatePathLock>,
1436}
1437
1438#[cfg(feature = "fixture")]
1439#[derive(Clone, Default)]
1440pub struct SpawnManagedWorktreeV2FailureProbe {
1441    latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1442}
1443
1444#[cfg(feature = "fixture")]
1445impl SpawnManagedWorktreeV2FailureProbe {
1446    pub fn latest_code(&self) -> Option<NodeFailureCode> {
1447        *self
1448            .latest_code
1449            .lock()
1450            .unwrap_or_else(|poisoned| poisoned.into_inner())
1451    }
1452
1453    fn record(&self, code: Option<NodeFailureCode>) {
1454        *self
1455            .latest_code
1456            .lock()
1457            .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1458    }
1459}
1460
1461#[cfg(all(feature = "fixture", windows))]
1462struct IsolatedCodexHomeFixtureEnvironment;
1463
1464#[cfg(all(feature = "fixture", windows))]
1465impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1466    for IsolatedCodexHomeFixtureEnvironment
1467{
1468    fn resolve_child_environment(
1469        &self,
1470    ) -> Result<
1471        Vec<gate4agent_catalog::EnvMutation>,
1472        gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1473    > {
1474        Ok(vec![gate4agent_catalog::EnvMutation {
1475            key: OsString::from("USERPROFILE"),
1476            value: None,
1477        }])
1478    }
1479}
1480
1481impl NodeServer {
1482    /// The letter-wake handle for the door, when m4a accounts are on. `token` is what callers
1483    /// must present.
1484    /// Turn on the m4a signer proxy for PTY spawns (replaces `M4A_SESSION_CAP`).
1485    #[cfg(unix)]
1486    pub fn set_signer_proxy(&self, proxy: Arc<crate::keystore_proxy::SignerProxy>) -> bool {
1487        self.shared.signer_proxy.set(proxy).is_ok()
1488    }
1489
1490    pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1491        self.shared.m4a_accounts.as_ref()?;
1492        Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1493    }
1494
1495    pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1496        let catalog = active_registry()?;
1497        Self::new_with_registry(config, catalog)
1498    }
1499
1500    #[cfg(feature = "fixture")]
1501    pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1502        let mut spec = gate4agent_testkit::interactive_agent_spec();
1503        spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1504        spec.display_name = "Controlled Claude PTY fixture".to_owned();
1505        Self::new_fixture_with_spec(config, spec)
1506    }
1507
1508    #[cfg(feature = "fixture")]
1509    pub fn new_clean_exit_fixture(
1510        config: NodeServerConfig,
1511        fixture_root: PathBuf,
1512        started_marker: PathBuf,
1513        release_signal: PathBuf,
1514    ) -> Result<Self, NodeServerError> {
1515        let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1516            &fixture_root,
1517            &started_marker,
1518            &release_signal,
1519        )
1520        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1521        spec.id = AgentId::new("claude")
1522            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1523        spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1524        Self::new_fixture_with_spec(config, spec)
1525    }
1526
1527    /// A `claude` source whose session establishes a verified provider
1528    /// identity via an early `SessionStart` Hook post (so it is eligible for
1529    /// auto-export-at-exit and its native history is independently
1530    /// loadable), waits for `release_signal`, then exits cleanly — paired
1531    /// on the same Node with a plain `codex` target that never validates
1532    /// anything about what it receives. Built for A2 durable ContextPack
1533    /// E2E coverage: the test itself inspects materialized files and
1534    /// managed-session-record fields directly, rather than having the
1535    /// target script self-validate.
1536    #[cfg(all(feature = "fixture", windows))]
1537    pub fn new_durable_context_pack_clean_exit_fixture(
1538        config: NodeServerConfig,
1539        fixture_root: PathBuf,
1540        started_marker: PathBuf,
1541        release_signal: PathBuf,
1542        session_id: &str,
1543    ) -> Result<Self, NodeServerError> {
1544        let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1545            &fixture_root,
1546            &started_marker,
1547            &release_signal,
1548            session_id,
1549        )
1550        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1551        let source_id = AgentId::new("claude")
1552            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1553        let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1554            NodeServerError::Registry(
1555                "durable context pack clean-exit fixture source adapter is unavailable"
1556                    .to_owned(),
1557            )
1558        })?;
1559        source_spec.capabilities.adapters.history =
1560            source_provider.capabilities.adapters.history.clone();
1561        source_spec.id = source_id;
1562        source_spec.display_name =
1563            "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1564        // Both specs launch via `powershell.exe`; the registry's ambiguity
1565        // check keys on `detection.command` (+ aliases), not `launch.program`
1566        // -- give each a distinct detection identity, same precedent as
1567        // `context_pack_fixture_catalog`'s per-provider
1568        // `gate4agent-{provider}-context-fixture.cmd` commands.
1569        source_spec.detection.command =
1570            "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1571        source_spec.detection.aliases.clear();
1572
1573        let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1574        target_spec.id = AgentId::new("codex")
1575            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1576        target_spec.display_name =
1577            "Controlled Codex durable context-pack target fixture".to_owned();
1578        target_spec.detection.command =
1579            "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1580        target_spec.detection.aliases.clear();
1581
1582        let catalog = AgentRegistry::new([source_spec, target_spec])
1583            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1584        let mut server = Self::new_with_registry(config, catalog)?;
1585        Arc::get_mut(&mut server.shared)
1586            .expect("fixture Node shared state must remain uniquely owned before run")
1587            .fixture_semantic_hook_policy = true;
1588        Ok(server)
1589    }
1590
1591    #[cfg(feature = "fixture")]
1592    pub fn new_monitoring_hook_fixture(
1593        config: NodeServerConfig,
1594    ) -> Result<Self, NodeServerError> {
1595        let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1596        spec.id = AgentId::new("claude")
1597            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1598        let mut server = Self::new_fixture_with_spec(config, spec)?;
1599        Arc::get_mut(&mut server.shared)
1600            .expect("fixture Node shared state must remain uniquely owned before run")
1601            .fixture_semantic_hook_policy = true;
1602        Ok(server)
1603    }
1604
1605    #[cfg(all(feature = "fixture", windows))]
1606    pub fn new_provider_bundle_argv_fixture(
1607        config: NodeServerConfig,
1608        agent_id: AgentId,
1609        proof_path: PathBuf,
1610    ) -> Result<Self, NodeServerError> {
1611        let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1612        Self::new_fixture_with_spec(config, spec)
1613    }
1614
1615    #[cfg(all(feature = "fixture", windows))]
1616    pub fn new_provider_bundle_argv_hold_fixture(
1617        config: NodeServerConfig,
1618        agent_id: AgentId,
1619        proof_path: PathBuf,
1620        release_signal: PathBuf,
1621    ) -> Result<Self, NodeServerError> {
1622        let spec = Self::provider_bundle_argv_fixture_spec(
1623            agent_id,
1624            proof_path,
1625            Some(release_signal),
1626        )?;
1627        Self::new_fixture_with_spec(config, spec)
1628    }
1629
1630    #[cfg(all(feature = "fixture", windows))]
1631    fn provider_bundle_argv_fixture_spec(
1632        agent_id: AgentId,
1633        proof_path: PathBuf,
1634        release_signal: Option<PathBuf>,
1635    ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1636        if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1637            return Err(NodeServerError::Registry(
1638                "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1639            ));
1640        }
1641        let mut spec = gate4agent_testkit::interactive_agent_spec();
1642        spec.id = agent_id.clone();
1643        spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1644        let provider = builtin_registry()
1645            .get(&agent_id)
1646            .ok_or_else(|| {
1647                NodeServerError::Registry(
1648                    "provider bundle argv fixture adapter is unavailable".to_owned(),
1649                )
1650            })?;
1651        spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1652        if !proof_path.is_absolute()
1653            || !proof_path
1654                .parent()
1655                .is_some_and(|parent| parent.is_dir())
1656        {
1657            return Err(NodeServerError::Registry(
1658                "provider bundle argv proof path requires an existing absolute parent"
1659                    .to_owned(),
1660            ));
1661        }
1662        if let Some(release_signal) = release_signal.as_ref() {
1663            if !release_signal.is_absolute()
1664                || !release_signal
1665                    .parent()
1666                    .is_some_and(|parent| parent.is_dir())
1667            {
1668                return Err(NodeServerError::Registry(
1669                    "provider bundle argv release path requires an existing absolute parent"
1670                        .to_owned(),
1671                ));
1672            }
1673            if release_signal.exists() {
1674                return Err(NodeServerError::Registry(
1675                    "provider bundle argv release path must not exist".to_owned(),
1676                ));
1677            }
1678            let proof_parent = std::fs::canonicalize(
1679                proof_path
1680                    .parent()
1681                    .expect("validated provider bundle proof parent"),
1682            )
1683            .map_err(|_| {
1684                NodeServerError::Registry(
1685                    "provider bundle argv proof parent is unavailable".to_owned(),
1686                )
1687            })?;
1688            let release_parent = std::fs::canonicalize(
1689                release_signal
1690                    .parent()
1691                    .expect("validated provider bundle release parent"),
1692            )
1693            .map_err(|_| {
1694                NodeServerError::Registry(
1695                    "provider bundle argv release parent is unavailable".to_owned(),
1696                )
1697            })?;
1698            if proof_parent != release_parent {
1699                return Err(NodeServerError::Registry(
1700                    "provider bundle argv release path must share the proof parent".to_owned(),
1701                ));
1702            }
1703        }
1704        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1705            NodeServerError::Registry(
1706                "provider bundle argv proof path is not valid Unicode".to_owned(),
1707            )
1708        })?;
1709        let release_signal = release_signal.map(|release_signal| {
1710            release_signal.into_os_string().into_string().map_err(|_| {
1711                NodeServerError::Registry(
1712                    "provider bundle argv release path is not valid Unicode".to_owned(),
1713                )
1714            })
1715        }).transpose()?;
1716        let script = spec
1717            .launch
1718            .fixed_args
1719            .pop()
1720            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1721        let bundle_validation = if agent_id.as_str() == "codex" {
1722            "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') }"
1723        } else if agent_id.as_str() == "claude" {
1724            "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 }"
1725        } else {
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 '--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 }"
1727        };
1728        if let Some(release_signal) = release_signal {
1729            spec.launch.fixed_args.push(format!(
1730                "& {{ 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} }}",
1731            ));
1732            spec.launch.fixed_args.push(proof_path);
1733            spec.launch.fixed_args.push(release_signal);
1734        } else {
1735            spec.launch.fixed_args.push(format!(
1736                "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1737            ));
1738            spec.launch.fixed_args.push(proof_path);
1739        }
1740        Ok(spec)
1741    }
1742
1743    #[cfg(all(feature = "fixture", windows))]
1744    pub fn new_context_pack_fixture(
1745        config: NodeServerConfig,
1746        proof_path: PathBuf,
1747    ) -> Result<Self, NodeServerError> {
1748        let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1749        Self::new_with_registry(config, catalog)
1750    }
1751
1752    #[cfg(all(feature = "fixture", windows))]
1753    pub fn new_monitoring_context_pack_fixture(
1754        config: NodeServerConfig,
1755        proof_path: PathBuf,
1756    ) -> Result<Self, NodeServerError> {
1757        let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1758        let mut server = Self::new_with_registry(config, catalog)?;
1759        Arc::get_mut(&mut server.shared)
1760            .expect("fixture Node shared state must remain uniquely owned before run")
1761            .fixture_semantic_hook_policy = true;
1762        Ok(server)
1763    }
1764
1765    #[cfg(all(feature = "fixture", windows))]
1766    pub fn install_isolated_codex_home_environment_fixture(
1767        &mut self,
1768        profile_id: SpawnEnvironmentProfileId,
1769        profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1770    ) -> Result<(), NodeServerError> {
1771        let provider = AgentId::new("codex")
1772            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1773        let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1774            gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1775                "{}-pty",
1776                profile_id.as_str(),
1777            ))
1778            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1779            provider.clone(),
1780            TransportKind::Pty,
1781            vec![OsString::from("USERPROFILE")],
1782            Arc::new(IsolatedCodexHomeFixtureEnvironment),
1783        )
1784        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1785        let materialization = NodeSessionMaterializationProfile::new(
1786            Vec::new(),
1787            vec![crate::session_environment::NodeSessionPathBinding::new(
1788                "CODEX_HOME",
1789                crate::session_environment::NodeSessionPathClass::ProviderHome,
1790            )
1791            .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1792            Vec::new(),
1793        )
1794        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1795        self.install_environment_profile(
1796            NodeEnvironmentProfile::new_with_materialization(
1797                profile_id,
1798                profile_revision,
1799                provider,
1800                [native_profile],
1801                Some(materialization),
1802            )
1803            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1804        )
1805    }
1806
1807    #[cfg(all(feature = "fixture", windows))]
1808    pub fn new_context_only_proof_fixture(
1809        config: NodeServerConfig,
1810        target_provider: AgentId,
1811        proof_path: PathBuf,
1812    ) -> Result<Self, NodeServerError> {
1813        if target_provider.as_str() != "kimi" {
1814            return Err(NodeServerError::Registry(
1815                "context-only proof fixture requires Kimi".to_owned(),
1816            ));
1817        }
1818        let catalog = Self::context_pack_fixture_catalog(
1819            proof_path,
1820            Some(&target_provider),
1821            false,
1822        )?;
1823        Self::new_with_registry(config, catalog)
1824    }
1825
1826    #[cfg(all(feature = "fixture", windows))]
1827    fn context_pack_fixture_catalog(
1828        proof_path: PathBuf,
1829        context_only_target: Option<&AgentId>,
1830        monitoring_claude_source: bool,
1831    ) -> Result<AgentRegistry, NodeServerError> {
1832        if !proof_path.is_absolute()
1833            || !proof_path
1834                .parent()
1835                .is_some_and(|parent| parent.is_dir())
1836        {
1837            return Err(NodeServerError::Registry(
1838                "context pack proof path requires an existing absolute parent".to_owned(),
1839            ));
1840        }
1841        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1842            NodeServerError::Registry(
1843                "context pack proof path is not valid Unicode".to_owned(),
1844            )
1845        })?;
1846        let providers = ["claude", "codex", "grok", "kimi"];
1847        let builtins = builtin_registry();
1848        let mut specs = Vec::with_capacity(providers.len());
1849        for provider_id in providers {
1850            let agent_id = AgentId::new(provider_id)
1851                .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1852            let provider = builtins.get(&agent_id).ok_or_else(|| {
1853                NodeServerError::Registry(
1854                    "context pack fixture provider adapter is unavailable".to_owned(),
1855                )
1856            })?;
1857            let monitored_provider =
1858                monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1859            let mut spec = if monitored_provider {
1860                gate4agent_testkit::monitoring_hook_agent_spec()
1861            } else {
1862                gate4agent_testkit::interactive_agent_spec()
1863            };
1864            spec.id = agent_id.clone();
1865            spec.display_name = format!("Controlled {agent_id} context pack fixture");
1866            spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1867            spec.detection.aliases.clear();
1868            spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1869            if provider_id == "codex" {
1870                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1871                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1872                })?;
1873                let validation = r#"
1874$bundleArgs = @($bundleArgs)
1875if ($bundleArgs.Count -ne 0) { exit 91 }
1876$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1877$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1878$cwd = (Get-Location).ProviderPath
1879if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1880$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1881if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1882$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1883if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1884$sha = [Security.Cryptography.SHA256]::Create()
1885try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1886if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1887if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1888$contextPath = Join-Path $contextRoot 'context-pack.json'
1889if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1890$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1891if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1892$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1893$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1894$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1895$separator = [IO.Path]::DirectorySeparatorChar
1896if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1897if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1898$rawContext = [IO.File]::ReadAllText($contextPath)
1899if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1900try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1901$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1902$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1903foreach ($property in @($document.PSObject.Properties.Name)) {
1904    if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1905}
1906foreach ($property in $requiredTopLevel) {
1907    if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1908}
1909if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1910if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1911$messages = @($document.retained_messages)
1912if ($messages.Count -eq 0) { exit 107 }
1913foreach ($message in $messages) {
1914    $messageProperties = @($message.PSObject.Properties.Name)
1915    foreach ($property in $messageProperties) {
1916        if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1917    }
1918    if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1919}
1920$sha = [Security.Cryptography.SHA256]::Create()
1921try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1922[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))
1923$rawProof = [IO.File]::ReadAllText($proofPath)
1924if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1925[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1926"#;
1927                spec.launch.fixed_args.push(format!(
1928                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1929                ));
1930                spec.launch.fixed_args.push(proof_path.clone());
1931            } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1932                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1933                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1934                })?;
1935                let validation = r#"
1936$bundleArgs = @($bundleArgs)
1937if ($bundleArgs.Count -ne 0) { exit 91 }
1938$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1939$cwd = (Get-Location).ProviderPath
1940if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1941$contextPath = Join-Path $contextRoot 'context-pack.json'
1942if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1943$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1944if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1945$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1946$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1947$separator = [IO.Path]::DirectorySeparatorChar
1948if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1949try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1950if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1951if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1952$messages = @($document.retained_messages)
1953if ($messages.Count -eq 0) { exit 99 }
1954$hasUser = $false
1955$hasAssistant = $false
1956foreach ($message in $messages) {
1957    if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1958    if ([string]$message.role -ceq 'user') { $hasUser = $true }
1959    elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1960    else { exit 101 }
1961}
1962if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1963$sha = [Security.Cryptography.SHA256]::Create()
1964try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1965[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1966[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1967"#;
1968                spec.launch.fixed_args.push(format!(
1969                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1970                ));
1971                spec.launch.fixed_args.push(proof_path.clone());
1972            }
1973            specs.push(spec);
1974        }
1975        let catalog = AgentRegistry::new(specs)
1976            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1977        Ok(catalog)
1978    }
1979
1980    #[cfg(feature = "fixture")]
1981    pub fn new_exact_launcher_fixture(
1982        config: NodeServerConfig,
1983        agent_id: AgentId,
1984        launcher: String,
1985    ) -> Result<Self, NodeServerError> {
1986        if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1987            return Err(NodeServerError::Registry(
1988                "exact launcher fixture requires a supported provider".to_owned(),
1989            ));
1990        }
1991        if launcher.contains('\0') {
1992            return Err(NodeServerError::Registry(
1993                "exact launcher fixture path contains NUL".to_owned(),
1994            ));
1995        }
1996        let launcher_path = Path::new(&launcher);
1997        if !launcher_path.is_absolute() {
1998            return Err(NodeServerError::Registry(
1999                "exact launcher fixture path must be absolute".to_owned(),
2000            ));
2001        }
2002        let metadata = std::fs::metadata(launcher_path).map_err(|_| {
2003            NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
2004        })?;
2005        if !metadata.is_file() {
2006            return Err(NodeServerError::Registry(
2007                "exact launcher fixture path must be a regular file".to_owned(),
2008            ));
2009        }
2010        let mut spec = builtin_registry()
2011            .get(&agent_id)
2012            .ok_or_else(|| {
2013                NodeServerError::Registry(
2014                    "exact launcher fixture provider is unavailable".to_owned(),
2015                )
2016            })?
2017            .clone();
2018        spec.launch.program = launcher;
2019        Self::new_fixture_with_spec(config, spec)
2020    }
2021
2022    /// Shared H3B validation and spec construction for
2023    /// `new_harness_mcp_proxy_fixture` and
2024    /// `new_harness_mcp_acp_launcher_fixture`: an absolute, exact-regular-
2025    /// file provider launcher plus at most 32 Unicode, NUL-free fixed
2026    /// arguments (65,536 bytes total), replacing `spec.launch` on a clone of
2027    /// the named builtin catalog entry -- detection command and expected
2028    /// process matcher rebased on the launcher's own basename, exactly the
2029    /// same shape `new_harness_mcp_proxy_fixture` always built.
2030    #[cfg(feature = "fixture")]
2031    fn h3b_provider_spec_with_program_and_args(
2032        agent_id: AgentId,
2033        provider_program: PathBuf,
2034        fixed_args: Vec<OsString>,
2035    ) -> Result<AgentSpec, NodeServerError> {
2036        if !provider_program.is_absolute() || fixed_args.len() > 32 {
2037            return Err(NodeServerError::Registry(
2038                "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2039            ));
2040        }
2041        let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2042            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2043        })?;
2044        if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2045            return Err(NodeServerError::Registry(
2046                "H3B fixture provider must be an exact regular file".to_owned(),
2047            ));
2048        }
2049        let program = provider_program.into_os_string().into_string().map_err(|_| {
2050            NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2051        })?;
2052        let fixed_args = fixed_args.into_iter().map(|argument| {
2053            argument.into_string().map_err(|_| NodeServerError::Registry(
2054                "H3B fixture argument is not Unicode".to_owned(),
2055            ))
2056        }).collect::<Result<Vec<_>, _>>()?;
2057        let argument_bytes = fixed_args.iter()
2058            .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2059        if program.contains('\0')
2060            || fixed_args.iter().any(|argument| argument.contains('\0'))
2061            || !matches!(argument_bytes, Some(total) if total <= 65_536)
2062        {
2063            return Err(NodeServerError::Registry(
2064                "H3B fixture provider or arguments are invalid".to_owned(),
2065            ));
2066        }
2067        let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2068            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2069        })?.clone();
2070        let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2071            .ok_or_else(|| NodeServerError::Registry(
2072                "H3B fixture provider basename is unavailable".to_owned(),
2073            ))?.to_owned();
2074        spec.detection.command = process_name.clone();
2075        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2076            name: process_name,
2077        }];
2078        spec.launch.program = program;
2079        spec.launch.fixed_args = fixed_args;
2080        Ok(spec)
2081    }
2082
2083    #[cfg(feature = "fixture")]
2084    pub fn new_harness_mcp_proxy_fixture(
2085        config: NodeServerConfig,
2086        provider_program: PathBuf,
2087        fixed_args: Vec<OsString>,
2088        harness_mcp_program: PathBuf,
2089    ) -> Result<Self, NodeServerError> {
2090        let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2091        let agent_id = AgentId::new("codex")
2092            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2093        let mut spec =
2094            Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2095        spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2096        config.fixture_raw_pty_runtime = true;
2097        Self::new_fixture_with_spec(config, spec)
2098    }
2099
2100    /// The ACP-mode sibling of `new_harness_mcp_proxy_fixture`: the same
2101    /// `--harness-mcp-helper` wiring and the same absolute-program-plus-
2102    /// fixed-args launcher replacement as `new_exact_launcher_fixture`, but
2103    /// admitting `ProviderRuntimeRequirement::Acp` the honest way instead of
2104    /// hardcoding it. `new_harness_mcp_proxy_fixture` forces `config.
2105    /// fixture_raw_pty_runtime = true`, which tears out the live
2106    /// `ProviderRuntimeMonitor` entirely (`NodeServer::new_with_registry`)
2107    /// and makes `NodeShared::admit_provider_runtime`'s no-monitor branch
2108    /// hardcode `acp_transport = false` for every provider, refusing
2109    /// `ProviderRuntimeRequirement::Acp` unconditionally regardless of what
2110    /// the catalog declares. This constructor leaves `fixture_raw_pty_
2111    /// runtime` at its default `false`, so a real `ProviderRuntimeMonitor`
2112    /// is built from the (cloned, launcher-replaced) catalog spec, and
2113    /// `ProviderRuntimeMonitor::supports_acp_transport` reads the honest,
2114    /// unmodified catalog fact: every builtin provider this accepts
2115    /// (`claude`, `codex`, `grok`, `kimi`) already declares `capabilities.
2116    /// transports.acp` in `gate4agent-catalog::builtin`, so no capability is
2117    /// invented here -- this constructor only removes the fixture-only
2118    /// bypass standing in its way. `provider_runtime::policy_for_transport`'s
2119    /// `TransportKind::Acp` arm (applied unconditionally at spawn-prep time
2120    /// for every ACP session, fixture or not) is what actually grants the
2121    /// ACP-shaped `ProviderRuntimePolicy` downstream of that admission --
2122    /// this constructor never calls it directly, and does not need to.
2123    #[cfg(feature = "fixture")]
2124    pub fn new_harness_mcp_acp_launcher_fixture(
2125        config: NodeServerConfig,
2126        helper_program: PathBuf,
2127        provider: AgentId,
2128        program: PathBuf,
2129        args: Vec<OsString>,
2130    ) -> Result<Self, NodeServerError> {
2131        if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2132            return Err(NodeServerError::Registry(
2133                "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2134            ));
2135        }
2136        let config = config.with_harness_mcp_helper(helper_program)?;
2137        let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2138        let launch = spec.launch.clone();
2139        let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2140            NodeServerError::Registry(
2141                "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2142                    .to_owned(),
2143            )
2144        })?;
2145        // Routes the ACP spawn itself through the exact replaced launcher
2146        // (`AcpSession::spawn_with_launch` -> a bare `Command::new(&launch.
2147        // program)`, `gate4agent`'s `src/acp/spawn.rs`) instead of the
2148        // catalog's own vendor-npm resolution (`acp_command`, same file) --
2149        // without this the ACP transport ignores `spec.launch` entirely and
2150        // tries to spawn the real vendor CLI (e.g. `npx -y @agentclientprotocol/
2151        // codex-acp@1.10.0`), which this fixture must never reach.
2152        acp.launch_override = Some(launch);
2153        spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2154        Self::new_fixture_with_spec(config, spec)
2155    }
2156
2157    #[cfg(feature = "fixture")]
2158    pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2159        let mut spec = gate4agent_testkit::interactive_agent_spec();
2160        let claude_id = AgentId::new("claude")
2161            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2162        let claude = builtin_registry()
2163            .get(&claude_id)
2164            .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2165        spec.id = claude_id;
2166        spec.display_name = "Resumable Claude PTY fixture".to_owned();
2167        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2168            name: "claude".to_owned(),
2169        }];
2170        spec.capabilities.transports.pty_adapter = claude
2171            .capabilities
2172            .transports
2173            .pty_adapter
2174            .clone();
2175        spec.capabilities.adapters.resume = claude
2176            .capabilities
2177            .adapters
2178            .resume
2179            .clone();
2180        let script = spec
2181            .launch
2182            .fixed_args
2183            .pop()
2184            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2185        spec.launch.fixed_args.push(format!(
2186            "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2187        ));
2188        Self::new_fixture_with_spec(config, spec)
2189    }
2190
2191    #[cfg(feature = "fixture")]
2192    pub fn new_cmd_cwd_fixture(
2193        config: NodeServerConfig,
2194        command: String,
2195    ) -> Result<Self, NodeServerError> {
2196        let mut spec = gate4agent_testkit::interactive_agent_spec();
2197        spec.id = AgentId::new("claude")
2198            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2199        spec.display_name = "Controlled cmd cwd fixture".to_owned();
2200        spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2201        spec.launch.program = command;
2202        spec.launch.fixed_args.clear();
2203        Self::new_fixture_with_spec(config, spec)
2204    }
2205
2206    #[cfg(feature = "fixture")]
2207    fn new_fixture_with_spec(
2208        config: NodeServerConfig,
2209        spec: gate4agent_types::AgentSpec,
2210    ) -> Result<Self, NodeServerError> {
2211        let catalog = AgentRegistry::new([spec])
2212            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2213        Self::new_with_registry(config, catalog)
2214    }
2215
2216    fn new_with_registry(
2217        config: NodeServerConfig,
2218        catalog: AgentRegistry,
2219    ) -> Result<Self, NodeServerError> {
2220        for profile in config.spawn_profiles.iter() {
2221            if catalog.get(&profile.provider).is_none() {
2222                return Err(NodeServerError::Registry(
2223                    "spawn profile references an unavailable provider".to_owned(),
2224                ));
2225            }
2226        }
2227        let input_settle_timeout_ms = catalog
2228            .iter()
2229            .map(|spec| spec.readiness.timeout_ms)
2230            .max()
2231            .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2232            .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2233            .max(MUTATION_SETTLE_TIMEOUT_MS);
2234        let (provider_contracts, provider_adapter_contracts) =
2235            provider_contract_manifest(&catalog)?;
2236        let compatibility_support = node_compatibility_support_for_manifest(
2237            &provider_contracts,
2238            &provider_adapter_contracts,
2239        )?;
2240        validate_node_negotiated_handshake_capacity(&compatibility_support)
2241            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2242        let enabled_providers = provider_contracts
2243            .iter()
2244            .map(|contract| contract.provider.clone())
2245            .collect();
2246        #[cfg(feature = "fixture")]
2247        let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2248        #[cfg(not(feature = "fixture"))]
2249        let fixture_raw_pty_runtime = false;
2250        let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2251            let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2252                crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2253            })).expect("fixture provider catalog remains bounded");
2254            (None, statuses)
2255        } else {
2256            let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2257            let statuses = monitor.collect();
2258            (Some(monitor), statuses)
2259        };
2260        let state_path_lock =
2261            session_registry::StatePathLock::acquire(config.state_path.as_deref())
2262                .map_err(|error| {
2263                    durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2264                })?;
2265        let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2266            Some(state_path) => {
2267                let delivery_root = delivery_store_root_for_state_path(state_path)
2268                    .ok_or_else(|| {
2269                        NodeServerError::DeliveryStore(
2270                            "delivery store root could not be derived from state path".to_owned(),
2271                        )
2272                    })?;
2273                let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2274                    let detail = match &error {
2275                        DeliveryStoreError::Storage(io_error) => {
2276                            format!("{error}: {io_error}")
2277                        }
2278                        _ => error.to_string(),
2279                    };
2280                    NodeServerError::DeliveryStore(detail)
2281                })?;
2282                (Some(store), bundles)
2283            }
2284            None => (None, Vec::new()),
2285        };
2286        let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2287            NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2288        })?;
2289        let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2290            Some(state_path) => {
2291                let context_pack_root = context_pack_store_root_for_state_path(state_path)
2292                    .ok_or(NodeServerError::ContextPackStore)?;
2293                let (store, packs) = ContextPackStore::open(context_pack_root)
2294                    .map_err(|_| NodeServerError::ContextPackStore)?;
2295                (Some(store), packs)
2296            }
2297            None => (None, Vec::new()),
2298        };
2299        let mut durable_context_catalog = ContextPackCatalog::default();
2300        for pack in durable_context_packs {
2301            durable_context_catalog
2302                .insert(pack)
2303                .map_err(|_| NodeServerError::ContextPackStore)?;
2304        }
2305        let session_environment_materializer = config
2306            .session_environment
2307            .as_ref()
2308            .map(|environment| {
2309                SessionEnvironmentMaterializer::new(
2310                    environment.root.clone(),
2311                    Arc::clone(&environment.resolver),
2312                )
2313                .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2314            })
2315            .transpose()?;
2316        // Session launchers go through the unprivileged `exec-as` wrapper when run-as is set
2317        // (the provider version probe above already used the original catalog).
2318        #[cfg(unix)]
2319        let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2320        let native_session_catalog = config.history.clone().map(|history| {
2321            Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2322        });
2323        let (handle, runtime) = match config.history.clone() {
2324            Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2325            None => NativeRuntime::new(catalog, config.runtime),
2326        };
2327        let native_launch_profile_control = runtime.native_launch_profile_control();
2328        let harness_mcp_registry = config
2329            .harness_mcp_helper
2330            .clone()
2331            .map(HarnessMcpProxyRegistry::new);
2332        let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2333        let incarnation_id = random_incarnation_id()
2334            .map_err(NodeServerError::IncarnationIdentity)?;
2335        let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2336            .map_err(durable_state_load_error)?;
2337        let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2338        let managed_worktree_tombstones =
2339            std::mem::take(&mut loaded.managed_worktree_tombstones);
2340        let materializations = std::mem::take(&mut loaded.materializations);
2341        let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2342        let (workspaces, records, persistence_warning) =
2343            merge_durable_state(&config.workspaces, loaded)?;
2344        let mut shared = NodeShared::new_with_incarnation(
2345            handle,
2346            config.access_token.clone(),
2347            config.node_id.clone(),
2348            incarnation_id,
2349            workspaces,
2350            enabled_providers,
2351            provider_runtime_statuses,
2352            provider_runtime_monitor,
2353            provider_contracts,
2354            provider_adapter_contracts,
2355            config.spawn_profiles.clone(),
2356            Some(native_launch_profile_control),
2357            native_session_catalog,
2358            session_environment_materializer,
2359            config.state_path.clone(),
2360            records,
2361            managed_worktrees,
2362            managed_worktree_tombstones,
2363            materializations,
2364            persistence_warning,
2365            input_settle_timeout_ms,
2366        );
2367        shared.harness_mcp_registry = harness_mcp_registry.clone();
2368        shared.session_record_retention = config.session_record_retention;
2369        shared.network_allowlist_catalog =
2370            RwLock::new(config.network_allowlist_catalog.clone());
2371        shared.bundle_catalog = RwLock::new(delivered_catalog);
2372        shared.delivery_store = Mutex::new(delivery_store);
2373        shared.context_catalog = RwLock::new(durable_context_catalog);
2374        shared.context_pack_store = Mutex::new(context_pack_store);
2375        shared.managed_spawn_replays = Mutex::new(
2376            managed_spawn_replays
2377                .into_iter()
2378                .map(|record| (record.idempotency_key.clone(), record))
2379                .collect(),
2380        );
2381        let shared = Arc::new(shared);
2382        if let Some(registry) = harness_mcp_registry {
2383            let weak = Arc::downgrade(&shared);
2384            registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2385                Some(shared) => shared.publish_transient(event),
2386                None => false,
2387            }));
2388        }
2389        if shared.persistence_error().is_none() {
2390            shared.persist_state().map_err(|error| {
2391                durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2392            })?;
2393        }
2394        Ok(Self {
2395            config,
2396            runtime,
2397            shared,
2398            events,
2399            state_path_lock,
2400        })
2401    }
2402
2403    pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2404        NodeShutdownHandle {
2405            shared: Arc::clone(&self.shared),
2406        }
2407    }
2408
2409    #[cfg(feature = "fixture")]
2410    pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2411        &self,
2412    ) -> SpawnManagedWorktreeV2FailureProbe {
2413        self.shared
2414            .fixture_spawn_managed_worktree_v2_failure_probe
2415            .clone()
2416    }
2417
2418    /// Installs one immutable host-local environment profile before the server runs.
2419    ///
2420    /// Native resolvers remain inside the runtime; the shared node registry keeps
2421    /// only public IDs, revisions, provider bindings, and opaque native profile IDs.
2422    pub fn install_environment_profile(
2423        &mut self,
2424        profile: NodeEnvironmentProfile,
2425    ) -> Result<(), NodeServerError> {
2426        if !self.shared.enabled_providers.contains(profile.provider()) {
2427            return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2428                profile.provider().clone(),
2429            ));
2430        }
2431        let (binding, native_profiles, materialization) = profile.into_parts();
2432        if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2433            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2434        }
2435        let mut profiles = self
2436            .shared
2437            .environment_profiles
2438            .write()
2439            .unwrap_or_else(|poisoned| poisoned.into_inner());
2440        if profiles.contains_key(&binding.id) {
2441            return Err(NodeServerError::DuplicateEnvironmentProfile(
2442                binding.id.clone(),
2443            ));
2444        }
2445        if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2446            return Err(NodeServerError::EnvironmentProfileCapacity {
2447                max: MAX_NODE_ENVIRONMENT_PROFILES,
2448            });
2449        }
2450        for native_id in binding.native_profile_ids() {
2451            if profiles.values().any(|current| {
2452                current.native_profile_ids().any(|current_id| current_id == native_id)
2453            }) {
2454                return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2455                    native_id.to_string(),
2456                ));
2457            }
2458        }
2459
2460        let mut installed = Vec::with_capacity(native_profiles.len());
2461        for native_profile in native_profiles {
2462            let native_id = native_profile.id().clone();
2463            if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2464                for installed_id in installed.into_iter().rev() {
2465                    let _ = self.runtime.remove_native_launch_profile(&installed_id);
2466                }
2467                return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2468            }
2469            installed.push(native_id);
2470        }
2471        if let Some(materialization) = materialization {
2472            self.shared
2473                .environment_materialization_profiles
2474                .write()
2475                .unwrap_or_else(|poisoned| poisoned.into_inner())
2476                .insert(binding.id.clone(), materialization);
2477        }
2478        profiles.insert(binding.id.clone(), binding);
2479        Ok(())
2480    }
2481
2482    /// Installs one immutable host-local bundle before the server runs.
2483    pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2484        if self.shared.session_environment_materializer.is_none() {
2485            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2486        }
2487        let mut catalog = self
2488            .shared
2489            .bundle_catalog
2490            .write()
2491            .unwrap_or_else(|poisoned| poisoned.into_inner());
2492        catalog
2493            .insert_idempotent(bundle)
2494            .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2495        Ok(())
2496    }
2497
2498    pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2499        let shutdown = self.shutdown_handle();
2500        let run = self.run();
2501        tokio::pin!(run);
2502        tokio::select! {
2503            result = &mut run => result,
2504            signal = wait_for_ctrl_signal() => {
2505                signal?;
2506                let shutdown_result = shutdown.request_shutdown().await;
2507                let run_result = run.await;
2508                shutdown_result.and(run_result)
2509            }
2510        }
2511    }
2512
2513    pub async fn run(self) -> Result<(), NodeServerError> {
2514        let NodeServer {
2515            config,
2516            mut runtime,
2517            shared,
2518            events,
2519            state_path_lock: _state_path_lock,
2520        } = self;
2521        shared.reconcile_materializations();
2522        shared.reconcile_managed_worktrees().await;
2523        shared.retire_unavailable_session_records().await;
2524        shared.reconcile_context_pack_exports().await;
2525        // Lifecycle hooks are retired (owner ruling 2026-09-25): the node
2526        // observes sessions through ACP where a provider has it; there is no
2527        // hook ingress.
2528        let endpoint = config.endpoint.clone();
2529        let call_home = config.call_home;
2530        let call_home_identity = config.call_home_identity.clone();
2531        let api_listen = config.api_listen;
2532        let bridge_listen = config.bridge_listen;
2533        let bridge_underlay = config.bridge_underlay;
2534        let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2535            .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2536        let accept_shared = Arc::clone(&shared);
2537        let api_shared = Arc::clone(&shared);
2538        let bridge_shared = Arc::clone(&shared);
2539        let shutdown_shared = Arc::clone(&shared);
2540        let shutdown_timeout = Duration::from_millis(
2541            config.runtime.provider_shutdown_timeout_ms.max(1),
2542        );
2543        let result = {
2544            let runtime_loop = drive_runtime_until_shutdown(
2545                &mut runtime,
2546                events,
2547                Arc::clone(&shared),
2548                shutdown_timeout,
2549            );
2550            let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2551            let api_loop = http_api::run(api_listen, api_shared);
2552            let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2553            tokio::pin!(runtime_loop);
2554            tokio::pin!(accept_loop);
2555            tokio::pin!(api_loop);
2556            tokio::pin!(bridge_loop);
2557            tokio::select! {
2558                runtime_result = &mut runtime_loop => {
2559                    let shutdown_result = shutdown_shared
2560                        .begin_shutdown()
2561                        .await
2562                        .map_err(NodeServerError::ShutdownDispatch);
2563                    let (accept_result, api_result, bridge_result) =
2564                        tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2565                    match runtime_result {
2566                        Err(error) => Err(error),
2567                        Ok(()) => shutdown_result
2568                            .and(accept_result)
2569                            .and(api_result.map_err(NodeServerError::HttpApi))
2570                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2571                    }
2572                }
2573                accept_result = &mut accept_loop => {
2574                    let shutdown_result = shutdown_shared
2575                        .begin_shutdown()
2576                        .await
2577                        .map_err(NodeServerError::ShutdownDispatch);
2578                    let (runtime_result, api_result, bridge_result) =
2579                        tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2580                    match accept_result {
2581                        Err(error) => Err(error),
2582                        Ok(()) => shutdown_result
2583                            .and(runtime_result)
2584                            .and(api_result.map_err(NodeServerError::HttpApi))
2585                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2586                    }
2587                }
2588                api_result = &mut api_loop => {
2589                    let shutdown_result = shutdown_shared
2590                        .begin_shutdown()
2591                        .await
2592                        .map_err(NodeServerError::ShutdownDispatch);
2593                    let (runtime_result, accept_result, bridge_result) =
2594                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2595                    match api_result {
2596                        Err(error) => Err(NodeServerError::HttpApi(error)),
2597                        Ok(()) => shutdown_result
2598                            .and(runtime_result)
2599                            .and(accept_result)
2600                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2601                    }
2602                }
2603                bridge_result = &mut bridge_loop => {
2604                    let shutdown_result = shutdown_shared
2605                        .begin_shutdown()
2606                        .await
2607                        .map_err(NodeServerError::ShutdownDispatch);
2608                    let (runtime_result, accept_result, api_result) =
2609                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2610                    match bridge_result {
2611                        Err(error) => Err(NodeServerError::BridgeApi(error)),
2612                        Ok(()) => shutdown_result
2613                            .and(runtime_result)
2614                            .and(accept_result)
2615                            .and(api_result.map_err(NodeServerError::HttpApi)),
2616                    }
2617                }
2618            }
2619        };
2620        // Persistence gap fix: durable DeliveryStore / ContextPackStore hold
2621        // exclusive flock (Unix) / share-mode locks (Windows) inside NodeShared.
2622        // NodeShutdownHandle also clones that Arc, so without an explicit release
2623        // here a supervisor that keeps the shutdown handle cannot reopen the same
2624        // state path after run() returns (EAGAIN / Resource temporarily unavailable).
2625        // StatePathLock already drops with this stack frame (`_state_path_lock`).
2626        if let Ok(mut guard) = shared.delivery_store.lock() {
2627            *guard = None;
2628        }
2629        if let Ok(mut guard) = shared.context_pack_store.lock() {
2630            *guard = None;
2631        }
2632        result
2633    }
2634}
2635
2636async fn drive_runtime_until_shutdown(
2637    runtime: &mut NativeRuntime,
2638    events: EventSubscription,
2639    shared: Arc<NodeShared>,
2640    shutdown_timeout: Duration,
2641) -> Result<(), NodeServerError> {
2642    // Published once, never per iteration. The worker loops inside
2643    // `NativeRuntime` fold into this profile continuously; turning it into
2644    // percentiles is what costs, and that is `GET /metrics`'s bill to pay.
2645    let _ = shared
2646        .shell_efficiency_profile
2647        .set(runtime.shell_efficiency_profile());
2648    let mut shutdown_started = None;
2649    let mut last_retention_sweep = Instant::now();
2650    let mut last_provisional_reap = Instant::now();
2651    loop {
2652        // Every phase below is timed unconditionally -- see
2653        // `drive_loop_profile`'s own doc comment for why an always-on,
2654        // allocation-free ring is cheap enough to never need a feature
2655        // flag. This is the only place these numbers can be taken: the
2656        // phases are calls made directly from this loop, not something a
2657        // callee could time on our behalf.
2658        let runtime_tick_start = Instant::now();
2659        let tick_result = runtime.tick().await;
2660        let runtime_tick_elapsed = runtime_tick_start.elapsed();
2661
2662        let event_drain_start = Instant::now();
2663        // Every command the kernel refused, named. `NativeRuntimeTick`
2664        // has carried these outcomes all along and nothing read them, so a
2665        // rejected command -- a hook event denied by runtime policy, a
2666        // stale generation, an unsupported capability -- vanished at the
2667        // last step of the loop that produced it. A refusal the operator
2668        // cannot see is indistinguishable from an event that never arrived,
2669        // and telling those two apart is most of debugging this stack.
2670        for outcome in &tick_result.command_outcomes {
2671            if let Err(error) = &outcome.result {
2672                shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2673                tracing::warn!(
2674                    command_id = outcome.command_id.0,
2675                    cause = %error,
2676                    "control command rejected"
2677                );
2678                shared.record_rejected_command(
2679                    outcome.command_id,
2680                    error.to_string(),
2681                    error.is_unsupported_transport(),
2682                );
2683            }
2684        }
2685        let mut events_drained: u64 = 0;
2686        while let Ok(event) = events.try_recv() {
2687            events_drained += 1;
2688            let clean_exit = matches!(
2689                event.event,
2690                ControlEventKind::Exited { exit_code: Some(0), forced: false },
2691            );
2692            let instance_id = event.instance_id;
2693            let generation = event.generation;
2694            // Resolve and spawn before publish_control: publish_control's own
2695            // managed-record bookkeeping (reconcile_managed_record) downgrades
2696            // this exact record from Live to Dormant and clears
2697            // active_session for every Exited event, inside the same
2698            // synchronous call. Capturing (record_id, session) here — before
2699            // that runs — is the only way to observe them still Live and
2700            // still populated; the export itself must still run on its own
2701            // detached task (never awaited inline on this loop) because it
2702            // needs NativeRuntime::tick() to keep running to ever settle, and
2703            // tick() is only ever driven by this same loop. See
2704            // reconcile_context_pack_export_for_record's doc comment.
2705            if clean_exit {
2706                if let Some((record_id, session)) =
2707                    shared.managed_record_export_target_for_instance(instance_id, generation)
2708                {
2709                    let export_shared = Arc::clone(&shared);
2710                    tokio::spawn(async move {
2711                        export_shared
2712                            .reconcile_context_pack_export_for_record(&record_id, session)
2713                            .await;
2714                    });
2715                }
2716            }
2717            shared.publish_control(event);
2718        }
2719        let event_drain_elapsed = event_drain_start.elapsed();
2720
2721        let publish_terminal_frames_start = Instant::now();
2722        shared.publish_terminal_frames();
2723        let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2724
2725        // Physical PTY count and the runtime's own per-phase tick timings
2726        // both live inside `NativeRuntime`, which `GET /metrics` (reading
2727        // only `NodeShared`) cannot reach directly -- this loop is the one
2728        // place holding `&mut NativeRuntime`, so it is the only place that
2729        // can copy either out.
2730        shared
2731            .native_session_gauge
2732            .store(runtime.active_native_sessions(), Ordering::Relaxed);
2733        *shared
2734            .runtime_tick_profile
2735            .lock()
2736            .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2737
2738        let remainder_start = Instant::now();
2739        if shared.shutdown.load(Ordering::Acquire) {
2740            let started = *shutdown_started.get_or_insert_with(Instant::now);
2741            let snapshot = shared.handle.snapshot();
2742            let sessions_settled = snapshot.sessions.iter().all(|session| {
2743                !matches!(
2744                    session.status,
2745                    gate4agent_types::SessionStatus::Starting
2746                        | gate4agent_types::SessionStatus::Running
2747                        | gate4agent_types::SessionStatus::Stopping
2748                )
2749            });
2750            if runtime.active_native_sessions() == 0 && sessions_settled {
2751                return Ok(());
2752            }
2753            if started.elapsed() >= shutdown_timeout {
2754                return Err(NodeServerError::ShutdownTimedOut {
2755                    active_native_sessions: runtime.active_native_sessions(),
2756                });
2757            }
2758        }
2759        let remainder_elapsed = remainder_start.elapsed();
2760
2761        {
2762            let mut profile = shared
2763                .drive_loop_profile
2764                .lock()
2765                .unwrap_or_else(|poisoned| poisoned.into_inner());
2766            profile.record_runtime_tick(runtime_tick_elapsed);
2767            profile.record_event_drain(event_drain_elapsed);
2768            profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2769            profile.record_remainder(remainder_elapsed);
2770            profile.note_iteration(Instant::now());
2771        }
2772
2773        // "Produced nothing" means this iteration's `NativeRuntime::tick`
2774        // applied zero observations (`tick_result.observations_applied`,
2775        // which already folds in any terminal frames collected -- see
2776        // `NativeEffectDispatcher::drain_observations`) AND the
2777        // control-event subscription drained above forwarded zero events.
2778        // Command ingress and dispatched effects are deliberately NOT part
2779        // of this definition: an idle node still runs `step_control_plane`
2780        // and every provider supervisor's `tick` regardless, every 10ms --
2781        // so "nothing" here means nothing OBSERVABLE left this iteration,
2782        // matching what a reader would assume from `drive_loop_iterations`'s
2783        // own field name, not that the tick itself was free of work.
2784        shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2785        if tick_result.observations_applied == 0 && events_drained == 0 {
2786            shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2787        }
2788
2789        // A sweep cadence, not a policy value -- see
2790        // `SESSION_RECORD_RETENTION_SWEEP_PERIOD`'s own doc comment. The
2791        // policy itself (whether this does anything at all) lives entirely
2792        // in `NodeShared::session_record_retention`.
2793        if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2794            shared.retire_unavailable_session_records().await;
2795            last_retention_sweep = Instant::now();
2796        }
2797        // Provisional Spawn grace: kill PTYs that never got an HQ attach.
2798        if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2799            for address in shared.take_due_provisional_spawns() {
2800                shared.reap_provisional_spawn(address).await;
2801            }
2802            last_provisional_reap = Instant::now();
2803        }
2804
2805        // Fast-poll for 300ms after PTY input so its echo is read and published
2806        // within ~1ms; otherwise nap 10ms but wake at once on new input.
2807        let hot = shared
2808            .pty_input_marks
2809            .lock()
2810            .unwrap_or_else(|poisoned| poisoned.into_inner())
2811            .values()
2812            .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2813        if hot {
2814            sleep(Duration::from_millis(1)).await;
2815        } else {
2816            tokio::select! {
2817                _ = sleep(Duration::from_millis(10)) => {}
2818                _ = shared.pty_input_wake.notified() => {}
2819            }
2820        }
2821    }
2822}
2823
2824#[derive(Clone)]
2825pub struct NodeShutdownHandle {
2826    shared: Arc<NodeShared>,
2827}
2828
2829impl NodeShutdownHandle {
2830    pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2831        self.shared
2832            .begin_shutdown()
2833            .await
2834            .map_err(NodeServerError::ShutdownDispatch)
2835    }
2836}
2837
2838async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2839    #[cfg(windows)]
2840    {
2841    let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2842    let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2843    return tokio::select! {
2844        signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2845        signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2846    };
2847    }
2848    #[cfg(unix)]
2849    {
2850        let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2851        let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2852        tokio::select! {
2853            signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2854            signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2855        }
2856    }
2857}
2858
2859/// The providers this node actually runs.
2860///
2861/// `builtin_registry()` is a reference catalog of everything the project has
2862/// ever described; the fleet is this list, and it is deliberately short. A
2863/// provider only belongs here if it can be exercised for real -- there is a
2864/// subscription behind it and a failure in it can be reproduced and
2865/// debugged. Carrying one that cannot is carrying code nobody can verify,
2866/// which is worse than not supporting it: it looks supported.
2867///
2868/// A provider dropped from the fleet by this rule is removed outright, not
2869/// carried dark: no catalog entry, no adapter, no dead code claiming support
2870/// for something nobody can verify runs.
2871/// Host interactive shell for HQ terminal attach (no provider CLI, no network).
2872fn shell_host_spec() -> AgentSpec {
2873    // Windows nodes (incl. MSYS-hosted) have no `/bin/bash` for Win32 Path::is_file;
2874    // fleet inventory on op-box resolves `bash` to the WSL stub at
2875    // `/c/Windows/system32/bash`, which is not a host shell. Use ComSpec/cmd.
2876    #[cfg(windows)]
2877    let (detect_command, program, fixed_args, process_name) = {
2878        let program = std::env::var("ComSpec").unwrap_or_else(|_| {
2879            r"C:\Windows\System32\cmd.exe".to_owned()
2880        });
2881        ("cmd".to_owned(), program, Vec::<String>::new(), "cmd".to_owned())
2882    };
2883    #[cfg(not(windows))]
2884    let (detect_command, program, fixed_args, process_name) = (
2885        "bash".to_owned(),
2886        "/bin/bash".to_owned(),
2887        vec!["-i".to_owned()],
2888        "bash".to_owned(),
2889    );
2890    AgentSpec {
2891        id: AgentId::new("shell").expect("shell agent id"),
2892        revision: "hatchery:shell-pty-1".to_owned(),
2893        display_name: "Host shell".to_owned(),
2894        detection: DetectionSpec {
2895            command: detect_command,
2896            aliases: vec!["sh".to_owned(), "shell".to_owned(), "cmd".to_owned()],
2897            required_commands: Vec::new(),
2898            unsupported_platforms: Vec::new(),
2899        },
2900        launch: LaunchSpec {
2901            program,
2902            fixed_args,
2903        },
2904        expected_processes: vec![ProcessMatcher::Exact {
2905            name: process_name,
2906        }],
2907        prompt: PromptSpec {
2908            initial: InitialPromptMode::None,
2909            native_draft: None,
2910        },
2911        readiness: AgentReadinessSpec::default(),
2912        capabilities: AgentCapabilities {
2913            agent_commands: None,
2914            transports: AgentTransportCapabilities {
2915                pty: true,
2916                pty_adapter: None,
2917                pipe: None,
2918                acp: None,
2919            },
2920            adapters: AgentAdapterCapabilities::default(),
2921        },
2922        verification: SpecVerification::Gate4AgentVerified,
2923    }
2924}
2925
2926fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2927    let mut specs = vec![shell_host_spec()];
2928    specs.extend(
2929        builtin_registry()
2930            .iter()
2931            .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2932            .cloned(),
2933    );
2934    AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2935}
2936
2937fn provider_contract_manifest(
2938    catalog: &AgentRegistry,
2939) -> Result<
2940    (
2941        Vec<ProviderContractSupport>,
2942        Vec<ProviderAdapterContractSupport>,
2943    ),
2944    NodeServerError,
2945> {
2946    let mut provider_contracts = Vec::new();
2947    let mut provider_adapter_contracts = Vec::new();
2948    for spec in catalog.iter() {
2949        let provider = spec.id.clone();
2950        let revision = ProviderContractRevision::new(spec.revision.clone())
2951            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2952        provider_contracts.push(ProviderContractSupport {
2953            provider: provider.clone(),
2954            revision,
2955        });
2956        for (family, binding) in declared_adapter_bindings(spec)? {
2957            let revision = AdapterContractRevision::new(binding.revision.clone())
2958                .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2959            provider_adapter_contracts.push(ProviderAdapterContractSupport {
2960                provider: provider.clone(),
2961                family,
2962                adapter_id: binding.id.clone(),
2963                revision,
2964            });
2965        }
2966    }
2967    validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2968        .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2969    Ok((provider_contracts, provider_adapter_contracts))
2970}
2971
2972fn declared_adapter_bindings(
2973    spec: &AgentSpec,
2974) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2975    let mut bindings = Vec::new();
2976    push_declared_binding(
2977        &mut bindings,
2978        AdapterFamily::PtySemantic,
2979        spec.capabilities.transports.pty_adapter.as_ref(),
2980    )?;
2981    push_declared_binding(
2982        &mut bindings,
2983        AdapterFamily::Pipe,
2984        spec.capabilities.adapters.pty_sidecar.as_ref(),
2985    )?;
2986    let pipe_transport = spec.capabilities.transports.pipe.as_ref();
2987    let pipe_family = pipe_transport.map(|transport| match transport.protocol {
2988        gate4agent_types::PipeProtocol::SemanticNdjson
2989        | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
2990        gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
2991    });
2992    push_declared_binding(
2993        &mut bindings,
2994        AdapterFamily::Pipe,
2995        pipe_transport
2996            .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
2997            .map(|transport| &transport.adapter),
2998    )?;
2999    push_declared_binding(
3000        &mut bindings,
3001        AdapterFamily::Acp,
3002        spec.capabilities
3003            .transports
3004            .acp
3005            .as_ref()
3006            .map(|transport| &transport.adapter),
3007    )?;
3008    push_declared_binding(
3009        &mut bindings,
3010        AdapterFamily::Hook,
3011        spec.capabilities.adapters.hook.as_ref(),
3012    )?;
3013    push_declared_binding(
3014        &mut bindings,
3015        AdapterFamily::ManagedHook,
3016        spec.capabilities.adapters.managed_hook.as_ref(),
3017    )?;
3018    push_declared_binding(
3019        &mut bindings,
3020        AdapterFamily::OneShot,
3021        pipe_transport
3022            .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
3023            .map(|transport| &transport.adapter),
3024    )?;
3025    push_declared_binding(
3026        &mut bindings,
3027        AdapterFamily::OneShot,
3028        spec.capabilities.adapters.one_shot.as_ref(),
3029    )?;
3030    push_declared_binding(
3031        &mut bindings,
3032        AdapterFamily::History,
3033        spec.capabilities.adapters.history.as_ref(),
3034    )?;
3035    push_declared_binding(
3036        &mut bindings,
3037        AdapterFamily::Resume,
3038        spec.capabilities.adapters.resume.as_ref(),
3039    )?;
3040    push_declared_binding(
3041        &mut bindings,
3042        AdapterFamily::SessionOptions,
3043        spec.capabilities.adapters.session_options.as_ref(),
3044    )?;
3045    push_declared_binding(
3046        &mut bindings,
3047        AdapterFamily::CapabilityProbe,
3048        spec.capabilities.adapters.capability_probe.as_ref(),
3049    )?;
3050    Ok(bindings)
3051}
3052
3053fn push_declared_binding<'a>(
3054    bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3055    family: AdapterFamily,
3056    binding: Option<&'a AdapterBinding>,
3057) -> Result<(), NodeServerError> {
3058    let Some(binding) = binding else {
3059        return Ok(());
3060    };
3061    if let Some((_, existing)) = bindings
3062        .iter()
3063        .find(|(existing_family, _)| *existing_family == family)
3064    {
3065        if *existing == binding {
3066            return Ok(());
3067        }
3068        return Err(NodeServerError::ProviderContractManifest(format!(
3069            "provider declares conflicting {family:?} adapter bindings"
3070        )));
3071    }
3072    bindings.push((family, binding));
3073    Ok(())
3074}
3075
3076fn merge_durable_state(
3077    configured: &[WorkspaceConfig],
3078    mut loaded: LoadedNodeState,
3079) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3080    let mut workspaces = configured
3081        .iter()
3082        .cloned()
3083        .map(|workspace| (workspace.workspace_id.clone(), workspace))
3084        .collect::<BTreeMap<_, _>>();
3085    let mut roots = workspaces
3086        .values()
3087        .map(|workspace| {
3088            (
3089                platform::root_identity(&workspace.canonical_root),
3090                workspace.workspace_id.clone(),
3091            )
3092        })
3093        .collect::<BTreeMap<_, _>>();
3094    let mut warning = loaded
3095        .warning
3096        .take()
3097        .map(|message| session_registry::sanitized_persistence_summary(&message));
3098
3099    for (workspace_id, canonical_root) in loaded.workspaces {
3100        if let Some(existing) = workspaces.get(&workspace_id) {
3101            if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3102                return Err(NodeServerError::DurableState(io::Error::new(
3103                    io::ErrorKind::InvalidData,
3104                    DURABLE_STATE_CONFLICT_ERROR,
3105                )));
3106            }
3107            continue;
3108        }
3109        if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3110            return Err(NodeServerError::DurableState(io::Error::new(
3111                io::ErrorKind::InvalidData,
3112                DURABLE_STATE_CONFLICT_ERROR,
3113            )));
3114        }
3115        if !Path::new(&canonical_root).is_dir() {
3116            warning.get_or_insert_with(|| {
3117                session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3118            });
3119            continue;
3120        }
3121        let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3122            NodeServerError::DurableState(io::Error::new(
3123                io::ErrorKind::InvalidData,
3124                DURABLE_STATE_CONFLICT_ERROR,
3125            ))
3126        })?;
3127        roots.insert(
3128            platform::root_identity(&workspace.canonical_root),
3129            workspace.workspace_id.clone(),
3130        );
3131        workspaces.insert(workspace_id, workspace);
3132    }
3133
3134    for record in &mut loaded.records {
3135        match workspaces.get(&record.workspace_id) {
3136            Some(workspace)
3137                if platform::roots_equal(
3138                    &workspace.canonical_root,
3139                    windows_path_text(&record.canonical_root),
3140                ) => {}
3141            Some(_) => {
3142                return Err(NodeServerError::DurableState(io::Error::new(
3143                    io::ErrorKind::InvalidData,
3144                    DURABLE_STATE_CONFLICT_ERROR,
3145                )));
3146            }
3147            None => {
3148                record.state = ManagedSessionState::Unavailable;
3149                record.active_session = None;
3150                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3151            }
3152        }
3153    }
3154    Ok((workspaces.into_values().collect(), loaded.records, warning))
3155}
3156
3157/// One entry of `NodeShared::rejected_command_reasons` -- see that field's
3158/// own doc for the mechanism this exists to close.
3159struct RejectedCommandReason {
3160    message: String,
3161    /// True when the kernel refused this command specifically because the
3162    /// agent does not declare the requested transport (`KernelCommandError::
3163    /// UnsupportedTransport`) -- lets the reader choose the named
3164    /// `NodeFailureCode::UnsupportedTransport` over the generic
3165    /// `BackendOperationFailed` bucket, so this one well-known rejection
3166    /// keeps its identity all the way to the operator wire.
3167    unsupported_transport: bool,
3168    expires_at: Instant,
3169}
3170
3171struct NodeShared {
3172    handle: Gate4AgentHandle,
3173    access_token: String,
3174    node_id: NodeId,
3175    incarnation_id: NodeIncarnationId,
3176    started_at_unix_ms: u64,
3177    workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3178    worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3179    managed_worktree_profiles:
3180        BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3181    managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3182    enabled_providers: Vec<AgentId>,
3183    provider_runtime_statuses: ProviderRuntimeStatuses,
3184    provider_runtime_status_updates:
3185        RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3186    provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3187    provider_contracts: Vec<ProviderContractSupport>,
3188    provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3189    spawn_profiles: SpawnProfileRegistry,
3190    environment_profiles:
3191        RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3192    environment_materialization_profiles:
3193        RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3194    /// Station **network** allowlist catalog (opaque policy ids only).
3195    /// Empty by default — any `network_allowlist` on spawn must be registered
3196    /// here or resolve refuses with `UnknownNetworkAllowlist`. Optional
3197    /// startup load via `NodeServerConfig::with_network_allowlist_catalog(_file)`
3198    /// / env `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG`. Plan
3199    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 / §4.
3200    /// Never holds cookies / OAuth / proxy credentials.
3201    network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3202    /// Node-local exclusive dig2browser station profile leases (feature
3203    /// `dig2-station-probe`). Opaque `browser_profile_id` ↔ session holder;
3204    /// cleared on session end. Never ImportSession / cookies on C2.
3205    #[cfg(feature = "dig2-station-probe")]
3206    browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3207    bundle_catalog: RwLock<BundleCatalog>,
3208    delivery_store: Mutex<Option<DeliveryStore>>,
3209    context_pack_store: Mutex<Option<ContextPackStore>>,
3210    context_catalog: RwLock<ContextPackCatalog>,
3211    native_launch_profile_control: Option<NativeLaunchProfileControl>,
3212    harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3213    native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3214    session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3215    /// Per-session m4a accounts assigned at spawn (see [`crate::m4a_accounts`]).
3216    m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3217    /// nick -> the session that holds that m4a account (for letter wakes).
3218    m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3219    /// m4a session key per runtime this node gave an account.
3220    m4a_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3221    /// Keys a resume hands to its new runtime (same nick and mailbox).
3222    m4a_reuse_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3223    /// Per-session grok leader sockets (never the operator's shared leader).
3224    grok_leaders: Mutex<BTreeMap<AgentInstanceId, std::path::PathBuf>>,
3225    /// m4a signer proxy (option A): when set, PTY sessions get a process-bound handle
3226    /// instead of a bearer capability.
3227    #[cfg(unix)]
3228    signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3229    materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3230    spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3231    managed_spawn_replays:
3232        Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3233    controller: Mutex<Option<ControllerLease>>,
3234    /// Fresh spawns waiting for their first successful HQ attach. Removed on
3235    /// Resize/TerminalBytes (sessionize) or when the grace reaper stops them.
3236    provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3237    /// ACP launch options waiting for the agent to advertise its config options.
3238    pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3239    history: Mutex<NodeEventHistory>,
3240    event_tx: broadcast::Sender<NodeEventEnvelope>,
3241    terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3242    /// The outbound `NodeEvent::AgentStream` push channel -- its own
3243    /// dedicated, capability-gated broadcast, mirroring `terminal_event_tx`
3244    /// exactly rather than riding the durable `event_tx` history (see
3245    /// `publish`'s own assertion): a subscriber who never negotiated
3246    /// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` must never receive one of
3247    /// these, the same guarantee `include_terminal_frame_events` already
3248    /// gives terminal frames.
3249    agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3250    /// The outbound `NodeEvent::HarnessMcpReadCall` push channel -- mirrors
3251    /// `agent_stream_event_tx` exactly, its own dedicated, capability-gated
3252    /// broadcast rather than a ride on the durable `event_tx` history. A
3253    /// `HarnessMcpReadCall` is a request/response call a live proxy call is
3254    /// waiting on (`harness_mcp_proxy.rs`'s `begin_call`); the shared bus's
3255    /// finite capacity and its willingness to silently evict a lagging
3256    /// subscriber's backlog is exactly how the call vanished with neither
3257    /// side ever logging it (see `publish_transient`'s own doc).
3258    harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3259    terminal_frame_watermarks:
3260        Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3261    /// Wakes the drive loop as soon as PTY input is queued (no 10ms nap).
3262    pty_input_wake: tokio::sync::Notify,
3263    /// Per session: when the last PTY input was dispatched (latency probe +
3264    /// a short fast-poll window so the echo is published within ~1ms).
3265    pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3266    next_connection_id: AtomicU64,
3267    next_instance_id: AtomicU64,
3268    next_command_id: AtomicU64,
3269    shutdown: AtomicBool,
3270    shutdown_notify: Notify,
3271    preauth_slots: Arc<Semaphore>,
3272    authenticated_slots: Arc<Semaphore>,
3273    inspection_slots: Arc<Semaphore>,
3274    mutation_gate: AsyncMutex<()>,
3275    session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3276    session_records: Mutex<SessionRecords>,
3277    state_path: Option<PathBuf>,
3278    persistence_error: RwLock<Option<String>>,
3279    state_transaction: Mutex<()>,
3280    input_settle_timeout_ms: u64,
3281    /// Drive-loop phase/rate timings -- see `drive_loop_profile`'s own doc
3282    /// comment. Written once per iteration by
3283    /// `drive_runtime_until_shutdown`, read by `GET /metrics`.
3284    drive_loop_profile: Mutex<DriveLoopProfiler>,
3285    /// Latest copy of `NativeRuntime::tick_profile_snapshot()` -- `Copy`,
3286    /// so an overwrite each iteration is cheap. The runtime itself is not
3287    /// reachable from here (it is owned locally by
3288    /// `drive_runtime_until_shutdown`), so this is how its own per-phase
3289    /// tick timings reach `GET /metrics`.
3290    runtime_tick_profile: Mutex<TickProfileSnapshot>,
3291    /// The live shell-efficiency profile, shared with the per-instance
3292    /// worker loops that fold into it.
3293    ///
3294    /// Deliberately NOT the per-iteration snapshot copy `runtime_tick_
3295    /// profile` above keeps: producing a snapshot sorts every ring behind
3296    /// it, and that sort belongs on the read. A `OnceLock` rather than a
3297    /// field set at construction because `NodeShared` is built before the
3298    /// `NativeRuntime` that owns the profile exists -- the drive loop
3299    /// publishes it once, before its first iteration. Unset means the
3300    /// runtime has not started driving yet, which `GET /metrics` reports as
3301    /// empty series rather than as zeroes it never measured.
3302    shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3303    /// Physical PTY sessions the native runtime currently owns
3304    /// (`NativeRuntime::active_native_sessions`), refreshed once per drive
3305    /// loop iteration -- deliberately a separate count from
3306    /// `handle.snapshot().sessions.len()` (the control-plane kernel's own
3307    /// session bookkeeping), since the two can disagree and the gap is
3308    /// itself a diagnostic signal `GET /metrics` exposes.
3309    native_session_gauge: AtomicUsize,
3310    /// Lifetime totals for the per-connection serve loop, summed across
3311    /// every live connection.
3312    ///
3313    /// The drive loop's own phase timings account for about ten
3314    /// microseconds an iteration on an idle node, yet the process burns a
3315    /// quarter of a core — and burns it ONLY while a c2 is attached (0.5%
3316    /// with none, 23% with two, measured). That gap is somewhere in
3317    /// serving a connection, and there was no way to see it: iterations
3318    /// says whether the loop is spinning rather than sleeping, and events
3319    /// says whether it is spinning over real work or over nothing.
3320    connection_loop_iterations: AtomicU64,
3321    connection_events_sent: AtomicU64,
3322    /// Lifetime totals for `drive_runtime_until_shutdown`'s own iterations
3323    /// -- `drive_loop_iterations_idle` counts iterations that applied zero
3324    /// `NativeRuntime::tick` observations AND forwarded zero control
3325    /// events; see the exact "produced nothing" definition at the site in
3326    /// `drive_runtime_until_shutdown` that increments these. Read twice
3327    /// and divide by the interval, same rule as `connections` above --
3328    /// `idle / total` is backlog item 5's "wake on work" number.
3329    /// Lifetime count of control commands the kernel refused.
3330    rejected_commands_total: AtomicU64,
3331    /// The named reason behind a recent kernel rejection, by the
3332    /// `CommandId` `dispatch_bounded` handed back when it enqueued that
3333    /// command -- populated by `drive_runtime_until_shutdown` for every
3334    /// `Err` in a tick's `command_outcomes`, and drained by whichever
3335    /// spawn-dispatch waiter recognizes its own `Register`/`Start`
3336    /// `CommandId` among them.
3337    ///
3338    /// A `ControlCommand` only ever fails synchronously (a `NodeFailure`
3339    /// out of `dispatch`/`dispatch_bounded` itself) for a port-level ingress
3340    /// problem -- full or disconnected -- never for the kernel refusing the
3341    /// command's content (an unsupported transport, an unknown agent, ...);
3342    /// that verdict exists only once `NativeRuntime::tick` has processed the
3343    /// command, one drive-loop iteration later, in a different task than
3344    /// whichever caller is awaiting the spawn. Without this map, a spawn
3345    /// wait loop that polls `self.handle.snapshot()` for a session that
3346    /// will now never appear has no way to learn why -- it can only spin
3347    /// until its own commit deadline and report a generic "did not commit"
3348    /// timeout, discarding a reason the kernel already named precisely (see
3349    /// `KernelCommandError`'s variants, e.g. `UnsupportedTransport`).
3350    rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3351    drive_loop_iterations_total: AtomicU64,
3352    drive_loop_iterations_idle: AtomicU64,
3353    /// The operator-chosen age/keep-N policy the retention sweep applies to
3354    /// `ManagedSessionState::Unavailable` records. Both `0` by default --
3355    /// see `SessionRecordRetentionConfig`'s own doc comment.
3356    session_record_retention: SessionRecordRetentionConfig,
3357    /// Lifetime count of managed session records the retention sweep has
3358    /// retired. Same tier as `rejected_commands_total` above: a live,
3359    /// process-lifetime instrument, never persisted and never reloaded from
3360    /// `state-v1.json` -- a restart legitimately resets it, matching every
3361    /// other `*_total` counter here.
3362    retired_records_total: AtomicU64,
3363    #[cfg(feature = "fixture")]
3364    fixture_semantic_hook_policy: bool,
3365    #[cfg(feature = "fixture")]
3366    fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3367}
3368
3369impl Drop for NodeShared {
3370    fn drop(&mut self) {
3371        SessionLifecycleHook::on_shutdown(self);
3372    }
3373}
3374
3375#[derive(Clone, Debug, Eq, PartialEq)]
3376struct SessionBinding {
3377    workspace_id: WorkspaceId,
3378    generation: SessionGeneration,
3379    runtime_policy: ProviderRuntimePolicy,
3380    pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3381    record_id: Option<SessionRecordId>,
3382    managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3383    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3384    bundle: Option<ResolvedBundleReceipt>,
3385    context: Option<ResolvedContextPackReceipt>,
3386    materialization_id: Option<MaterializationId>,
3387}
3388
3389#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3390enum SpawnRecordPolicy {
3391    ProviderIdentityOnly,
3392    Always,
3393}
3394
3395struct NativeEnvironmentSelectionGuard {
3396    control: Option<NativeLaunchProfileControl>,
3397    instance_id: AgentInstanceId,
3398}
3399
3400struct NativeInstanceOverlayGuard {
3401    control: Option<NativeLaunchProfileControl>,
3402    instance_id: AgentInstanceId,
3403}
3404
3405struct NativeHarnessMcpOverlayGuard {
3406    control: NativeLaunchProfileControl,
3407    instance_id: AgentInstanceId,
3408}
3409
3410impl NativeHarnessMcpOverlayGuard {
3411    fn retain(self) { std::mem::forget(self); }
3412}
3413
3414impl Drop for NativeHarnessMcpOverlayGuard {
3415    fn drop(&mut self) {
3416        self.control
3417            .clear_native_mcp_server_launch_overlay(self.instance_id);
3418    }
3419}
3420
3421impl NativeInstanceOverlayGuard {
3422    fn retain(mut self) {
3423        self.control = None;
3424    }
3425}
3426
3427impl Drop for NativeInstanceOverlayGuard {
3428    fn drop(&mut self) {
3429        if let Some(control) = self.control.as_ref() {
3430            control.clear_native_instance_launch_overlay(self.instance_id);
3431        }
3432    }
3433}
3434
3435enum PreparedNativeLaunchOverlay {
3436    Environment(NativeLaunchEnvironmentOverlay),
3437    Instance(NativeInstanceLaunchOverlay),
3438}
3439
3440impl NativeEnvironmentSelectionGuard {
3441    fn retain(mut self) {
3442        self.control = None;
3443    }
3444}
3445
3446impl Drop for NativeEnvironmentSelectionGuard {
3447    fn drop(&mut self) {
3448        if let Some(control) = self.control.as_ref() {
3449            control.clear_native_launch_profile_selection(self.instance_id);
3450        }
3451    }
3452}
3453
3454struct SessionMaterializationGuard<'a> {
3455    shared: &'a NodeShared,
3456    id: Option<MaterializationId>,
3457    /// Absolute ProviderHome (`home/`) under the session materialization root.
3458    /// Used for Claude station network `--settings` sidecar writes.
3459    provider_home: Option<PathBuf>,
3460}
3461
3462impl SessionMaterializationGuard<'_> {
3463    fn id(&self) -> Option<&MaterializationId> {
3464        self.id.as_ref()
3465    }
3466
3467    fn provider_home(&self) -> Option<&Path> {
3468        self.provider_home.as_deref()
3469    }
3470
3471    fn retain(mut self) {
3472        self.id = None;
3473    }
3474}
3475
3476impl Drop for SessionMaterializationGuard<'_> {
3477    fn drop(&mut self) {
3478        if let Some(id) = self.id.take() {
3479            let _ = self.shared.cleanup_materialization(&id);
3480        }
3481    }
3482}
3483
3484
3485/// Port: product-side reactions to session lifecycle transitions
3486/// (station lease, harness MCP overlay/registry, environment launch overlays).
3487trait SessionLifecycleHook {
3488    /// A binding was removed (unbind or spawn rollback).
3489    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3490    /// A session reached a terminal control event that revokes its harness MCP door.
3491    fn on_session_ended(&self, address: &SessionAddress);
3492    /// Node shutdown (or drop): revoke every harness MCP reservation.
3493    fn on_shutdown(&self);
3494    /// A harness MCP reservation was aborted before activation.
3495    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3496    /// Release session-owned materialization for a removed binding.
3497    fn cleanup_session_materialization(
3498        &self,
3499        address: &SessionAddress,
3500        binding: &SessionBinding,
3501    ) -> Result<(), NodeFailure>;
3502}
3503
3504impl SessionLifecycleHook for NodeShared {
3505    fn on_session_ended(&self, address: &SessionAddress) {
3506        self.retire_session_grok_leader(address.session.instance_id);
3507        if let Some(accounts) = self.m4a_accounts.as_ref() {
3508            let instance = address.session.instance_id;
3509            let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3510            for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3511                accounts.remove_session_file(nick);
3512            }
3513        }
3514        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3515            registry.revoke_session(address);
3516        }
3517        if let Some(control) = self.native_launch_profile_control.as_ref() {
3518            control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3519        }
3520    }
3521
3522    fn on_shutdown(&self) {
3523        let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3524            .into_values()
3525            .collect();
3526        for socket in leaders {
3527            crate::grok_leader::retire(&socket);
3528        }
3529        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3530            let instances = registry.shutdown();
3531            if let Some(control) = self.native_launch_profile_control.as_ref() {
3532                for instance_id in instances {
3533                    control.clear_native_mcp_server_launch_overlay(instance_id);
3534                }
3535            }
3536        }
3537    }
3538
3539    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3540        if let Some(control) = self.native_launch_profile_control.as_ref() {
3541            control.clear_native_mcp_server_launch_overlay(instance_id);
3542        }
3543    }
3544
3545    fn cleanup_session_materialization(
3546        &self,
3547        address: &SessionAddress,
3548        binding: &SessionBinding,
3549    ) -> Result<(), NodeFailure> {
3550        self.cleanup_session_owned_materialization(address, binding)
3551    }
3552
3553    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3554        // Exclusive dig2browser station profile lease is node-local; clear
3555        // on every session unbind / rollback. Never ImportSession/cookies.
3556        #[cfg(feature = "dig2-station-probe")]
3557        {
3558            crate::dig2_station_lease::release_lease_for_holder(
3559                &self.browser_station_leases,
3560                address.session.instance_id,
3561            );
3562        }
3563        if let Some(control) = self.native_launch_profile_control.as_ref() {
3564            control.clear_native_mcp_server_launch_overlay(
3565                address.session.instance_id,
3566            );
3567        }
3568        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3569            registry.revoke_session(address);
3570        }
3571        if removed
3572            .is_some_and(|binding| {
3573                binding.environment_profile.is_some()
3574                    || binding.bundle.is_some()
3575                    || binding.context.is_some()
3576            })
3577        {
3578            if let Some(control) = self.native_launch_profile_control.as_ref() {
3579                control.clear_native_launch_profile_selection(
3580                    address.session.instance_id,
3581                );
3582                control.clear_native_instance_launch_overlay(
3583                    address.session.instance_id,
3584                );
3585            }
3586        }
3587    }
3588}
3589
3590struct SpawnPrepareRequest<'r> {
3591    instance_id: AgentInstanceId,
3592    address: &'r SessionAddress,
3593    provider: &'r AgentId,
3594    mode: SessionMode,
3595    environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3596    bundle: Option<&'r ResolvedBundleReceipt>,
3597    context: Option<&'r ResolvedContextPackReceipt>,
3598    managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3599    harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3600    approval_level: ApprovalLevel,
3601    network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3602    browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3603}
3604
3605/// Guards held between "session identity minted" and "launch committed".
3606/// Field order is the drop order and matches the pre-refactor local order
3607/// (instance overlay, environment selection, materialization, harness, station lease).
3608struct PreparedSpawn<'a> {
3609    instance_overlay: Option<NativeInstanceOverlayGuard>,
3610    environment_selection: Option<NativeEnvironmentSelectionGuard>,
3611    materialization_guard: Option<SessionMaterializationGuard<'a>>,
3612    harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3613    #[cfg(feature = "dig2-station-probe")]
3614    browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3615    materialization_id: Option<MaterializationId>,
3616}
3617
3618impl PreparedSpawn<'_> {
3619    /// Session-owned launch state now belongs to the binding.
3620    fn retain_session_guards(&mut self) {
3621        if let Some(selection) = self.environment_selection.take() {
3622            selection.retain();
3623        }
3624        if let Some(overlay) = self.instance_overlay.take() {
3625            overlay.retain();
3626        }
3627        if let Some(guard) = self.materialization_guard.take() {
3628            guard.retain();
3629        }
3630    }
3631
3632    /// Launch committed: keep harness overlay and station lease for the session lifetime.
3633    fn retain_launch_guards(&mut self) {
3634        if let Some(overlay) = self.harness_mcp_overlay.take() {
3635            overlay.retain();
3636        }
3637        #[cfg(feature = "dig2-station-probe")]
3638        if let Some(guard) = self.browser_station_lease_guard.take() {
3639            guard.retain();
3640        }
3641    }
3642}
3643
3644/// Port: everything product-side that must be prepared before a session is
3645/// bound and started (station lease, harness MCP overlay, materialization,
3646/// environment selection, network overlays, launch overlay).
3647trait SpawnPreparer {
3648    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3649    /// Select the native launch profile for an environment-profile session.
3650    fn select_environment(
3651        &self,
3652        instance_id: AgentInstanceId,
3653        provider: &AgentId,
3654        mode: SessionMode,
3655        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3656    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3657    /// Install a prepared launch overlay for the instance.
3658    fn install_launch_overlay(
3659        &self,
3660        instance_id: AgentInstanceId,
3661        overlay: PreparedNativeLaunchOverlay,
3662    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3663}
3664
3665impl NodeShared {
3666    /// m4a session key of a new runtime: a resume reuses the key (and so the nick and mailbox)
3667    /// of the runtime it replaces; a fresh spawn gets `<incarnation>-<instance>`.
3668    fn m4a_session_key(&self, instance_id: AgentInstanceId) -> String {
3669        let reused = self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
3670        let key = reused.unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0));
3671        self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key.clone());
3672        key
3673    }
3674
3675    fn session_workdir(&self, root: &std::path::Path, instance_id: AgentInstanceId, provider: &AgentId) -> Option<std::path::PathBuf> {
3676        let accounts = self.m4a_accounts.as_ref()?;
3677        let nick = self.m4a_nick_of(instance_id)?;
3678        match accounts.session_workdir(root, provider.as_str(), &nick) {
3679            Ok(dir) => dir,
3680            Err(error) => {
3681                tracing::warn!(%nick, %error, "session folder not prepared; the session runs in the workspace root");
3682                None
3683            }
3684        }
3685    }
3686
3687    fn m4a_nick_of(&self, instance_id: AgentInstanceId) -> Option<String> {
3688        self.m4a_live.lock().unwrap_or_else(|p| p.into_inner()).iter().find(|(_, (i, _))| *i == instance_id).map(|(n, _)| n.clone())
3689    }
3690
3691    /// The managed record of a node session carries its m4a nick as its name (HQ shows it,
3692    /// `/api/spawn` returns it).
3693    fn name_record_after_nick(&self, instance_id: AgentInstanceId, nick: &str) {
3694        let mut records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
3695        for record in records.records.values_mut() {
3696            if record.active_session.as_ref().is_some_and(|a| a.session.instance_id == instance_id) {
3697                record.display_name = nick.to_owned();
3698            }
3699        }
3700    }
3701
3702    /// Gives a node session its own m4a account: identity env, session file, MCP entry and
3703    /// the mailbox listener that wakes it.
3704    fn m4a_session_overlay(
3705        &self,
3706        accounts: &crate::m4a_accounts::M4aSessionAccounts,
3707        m4a_provider: &str,
3708        provider: &AgentId,
3709        instance_id: AgentInstanceId,
3710        environment_overlay: Option<PreparedNativeLaunchOverlay>,
3711        session_key: &str,
3712    ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3713        let session_key = session_key.to_owned();
3714        Ok(                match accounts.provision(m4a_provider, &session_key) {
3715                    Ok(account) => {
3716                        let mut identity = account.env_mutations();
3717                        identity.extend(crate::m4a_accounts::provider_session_env(provider.as_str()));
3718                        let merged = match environment_overlay {
3719                            None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3720                                .map(PreparedNativeLaunchOverlay::Instance),
3721                            Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3722                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3723                            }
3724                            Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3725                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3726                            }
3727                        }
3728                        .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3729                        tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3730                        let mut merged = merged;
3731                        match accounts.write_session_file(&account) {
3732                            Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3733                            Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3734                                Ok(args) if !args.is_empty() => {
3735                                    merged = match merged {
3736                                        PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3737                                            .append_extra_args(args)
3738                                            .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3739                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3740                                        PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3741                                            .into_instance_with_extra_args(args)
3742                                            .map(PreparedNativeLaunchOverlay::Instance)
3743                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3744                                    };
3745                                    tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3746                                }
3747                                Ok(_) => {}
3748                                Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3749                            },
3750                        }
3751                        self.m4a_live
3752                            .lock()
3753                            .unwrap_or_else(|p| p.into_inner())
3754                            .insert(account.nick.clone(), (instance_id, provider.clone()));
3755                        self.name_record_after_nick(instance_id, &account.nick);
3756                        tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a nick bound to session");
3757                        // Letters to this account wake the session: the node listens on its mailbox.
3758                        crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3759                        Some(merged)
3760                    }
3761                    Err(error) => {
3762                        tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3763                        environment_overlay
3764                    }
3765                })
3766    }
3767}
3768
3769impl SpawnPreparer for NodeShared {
3770    fn select_environment(
3771        &self,
3772        instance_id: AgentInstanceId,
3773        provider: &AgentId,
3774        mode: SessionMode,
3775        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3776    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3777        self.select_environment_profile(instance_id, provider, mode, environment_profile)
3778    }
3779
3780    fn install_launch_overlay(
3781        &self,
3782        instance_id: AgentInstanceId,
3783        overlay: PreparedNativeLaunchOverlay,
3784    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3785        self.install_prepared_launch_overlay(instance_id, overlay)
3786    }
3787
3788    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3789        let SpawnPrepareRequest {
3790            instance_id,
3791            address,
3792            provider,
3793            mode,
3794            environment_profile,
3795            bundle,
3796            context,
3797            managed_authority,
3798            harness_mcp,
3799            approval_level,
3800            network_allowlist,
3801            browser_profile_id,
3802        } = request;
3803        // Track A follow-on: exclusive node-local BrowserStationLease after
3804        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
3805        // never ImportSession / cookies on C2. Linux/feature-off: probe
3806        // already refused or stub; this block is cfg'd out when feature off.
3807        #[cfg(feature = "dig2-station-probe")]
3808        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3809            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3810            match acquire_lease(
3811                &self.browser_station_leases,
3812                profile_id,
3813                instance_id,
3814                unix_time_ms(),
3815            ) {
3816                Ok(guard) => Some(guard),
3817                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3818                    return Err(failure(
3819                        NodeFailureCode::BrowserStationProfileBusy,
3820                        "dig2browser station profile is already leased by another session",
3821                    ));
3822                }
3823                Err(BrowserStationLeaseError::CapacityExceeded) => {
3824                    return Err(failure(
3825                        NodeFailureCode::BrowserStationProfileBusy,
3826                        "dig2browser station profile lease capacity exhausted",
3827                    ));
3828                }
3829                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3830                    return Err(failure(
3831                        NodeFailureCode::BrowserStationProfileBusy,
3832                        "session already holds a dig2browser station profile lease",
3833                    ));
3834                }
3835            }
3836        } else {
3837            None
3838        };
3839        #[cfg(not(feature = "dig2-station-probe"))]
3840        let _ = browser_profile_id;
3841        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3842            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
3843            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
3844            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
3845            // both read the identical overlay resolved below, one as child
3846            // environment, the other translated into one stdio MCP server
3847            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
3848            // only, and nothing downstream reads a harness-MCP overlay for
3849            // that transport yet.
3850            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3851            {
3852                return Err(failure(
3853                    NodeFailureCode::BindingMismatch,
3854                    "harness MCP reservation does not match the exact PTY/ACP provider",
3855                ));
3856            }
3857            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3858            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3859                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3860            })?;
3861            // Every name below is the caller's, carried in the reservation's
3862            // launch description: which environment variables receive the
3863            // endpoint, token and helper program, what the MCP server is called,
3864            // which argv it gets and which stale variables are erased.
3865            let launch = prepared.launch();
3866            let mut env_entries = vec![
3867                (
3868                    OsString::from(launch.endpoint_env.as_str()),
3869                    prepared.endpoint().as_os_str().to_os_string(),
3870                ),
3871                (
3872                    OsString::from(launch.token_env.as_str()),
3873                    prepared.token().expose().into(),
3874                ),
3875                (
3876                    OsString::from(launch.program_env.as_str()),
3877                    prepared.helper_program().as_os_str().to_os_string(),
3878                ),
3879            ];
3880            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3881                env_entries.push(trace_entry);
3882            }
3883            if let Some(door) = crate::door::door_listen_addr() {
3884                env_entries.push((
3885                    OsString::from("HATCHERY_NODE_DOOR"),
3886                    OsString::from(door.to_string()),
3887                ));
3888                // Same key shape as HQ browser/draft ids:
3889                // `pty-{incarnation}-{workspace}-{instance}` — never reuse a
3890                // slot across node boots when the instance counter resets.
3891                let session_key = format!(
3892                    "pty-{}-{}-{}",
3893                    self.incarnation_id,
3894                    address.workspace_id.as_str(),
3895                    address.session.instance_id.0
3896                );
3897                env_entries.push((
3898                    OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3899                    OsString::from(session_key),
3900                ));
3901            }
3902            let overlay = NativeMcpServerLaunchOverlay::new(
3903                provider.clone(),
3904                launch.server_name.as_str(),
3905                prepared.helper_program().as_os_str().to_os_string(),
3906                launch.args.iter().map(OsString::from).collect(),
3907                env_entries,
3908                launch.scrub_env.iter().map(OsString::from).collect(),
3909            ).map_err(|_| failure(
3910                NodeFailureCode::HarnessMcpUnavailable,
3911                "harness MCP launch overlay is unavailable",
3912            ))?;
3913            control
3914                .install_native_mcp_server_launch_overlay(instance_id, overlay)
3915                .map_err(|_| failure(
3916                    NodeFailureCode::HarnessMcpUnavailable,
3917                    "harness MCP launch overlay could not be installed",
3918                ))?;
3919            // State-change line: the overlay is installed and will be read
3920            // back by the PTY child's environment or, for ACP,
3921            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
3922            // time -- no secrets here, only which instance/provider/mode
3923            // now carries a harness-MCP door.
3924            tracing::info!(
3925                node_id = %self.node_id,
3926                instance_id = ?instance_id,
3927                provider = %provider,
3928                mode = ?mode,
3929                "harness MCP overlay installed for spawn",
3930            );
3931            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
3932        } else {
3933            None
3934        };
3935        let prepared_materialization = self.prepare_session_materialization(
3936            &address,
3937            &provider,
3938            mode,
3939            environment_profile,
3940            bundle,
3941            context,
3942            managed_authority.cloned(),
3943        )?;
3944        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
3945            Some((overlay, guard)) => (Some(overlay), Some(guard)),
3946            None => (None, None),
3947        };
3948        let materialization_id = materialization_guard
3949            .as_ref()
3950            .and_then(SessionMaterializationGuard::id)
3951            .cloned();
3952        let mut environment_selection = SpawnPreparer::select_environment(self, 
3953            instance_id,
3954            &provider,
3955            mode,
3956            environment_profile,
3957        )?;
3958        // Station network overlays: Codex `-c network_access` and/or Claude
3959        // ProviderHome `--settings` sidecar (permits → allowedDomains).
3960        let provider_home = materialization_guard
3961            .as_ref()
3962            .and_then(SessionMaterializationGuard::provider_home);
3963        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
3964            &provider,
3965            mode,
3966            approval_level,
3967            network_allowlist,
3968            provider_home,
3969        )?;
3970        let mut network_overlay_args = network_overlay_args;
3971        if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
3972            network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
3973        }
3974        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
3975            (prepared, args) if args.is_empty() => prepared,
3976            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
3977                overlay.append_extra_args(args).map_err(|_| {
3978                    failure(
3979                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3980                        "network allowlist overlay could not merge with instance launch args",
3981                    )
3982                })?;
3983                Some(PreparedNativeLaunchOverlay::Instance(overlay))
3984            }
3985            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
3986                Some(PreparedNativeLaunchOverlay::Instance(
3987                    overlay
3988                        .into_instance_with_extra_args(args)
3989                        .map_err(|_| {
3990                            failure(
3991                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3992                                "network allowlist overlay could not merge with environment overlay",
3993                            )
3994                        })?,
3995                ))
3996            }
3997            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
3998                NativeInstanceLaunchOverlay::new(
3999                    provider.clone(),
4000                    match mode {
4001                        SessionMode::Pty => TransportKind::Pty,
4002                        SessionMode::Acp => TransportKind::Acp,
4003                        SessionMode::Inline => TransportKind::Pipe,
4004                    },
4005                    Vec::new(),
4006                    args,
4007                )
4008                .map_err(|_| {
4009                    failure(
4010                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4011                        "network allowlist overlay is invalid for this transport",
4012                    )
4013                })?,
4014            )),
4015        };
4016        // Per-session m4a account: own nick + capability for this PTY child only, on its own
4017        // overlay or appended to the overlay the spawn already carries.
4018        #[cfg(unix)]
4019        // `HATCHERY_M4A_SIGNER_SHELL=1` also gives operator shells an identity (live canaries).
4020        let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
4021            (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
4022        });
4023        #[cfg(unix)]
4024        let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
4025            (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
4026                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
4027                let uid = unsafe { libc_geteuid() };
4028                let handle = proxy.reserve(&session_key, m4a_provider, uid);
4029                let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
4030                tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
4031                Some(vec![
4032                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
4033                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
4034                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
4035                ])
4036            }
4037            _ => None,
4038        };
4039        #[cfg(not(unix))]
4040        let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
4041        let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
4042            (SessionMode::Pty, _, _, Some(identity)) => {
4043                let merged = match environment_overlay {
4044                    None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
4045                    Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
4046                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
4047                    }
4048                    Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
4049                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
4050                    }
4051                }
4052                .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
4053                Some(merged)
4054            }
4055            (SessionMode::Pty | SessionMode::Acp, Some(accounts), Some(m4a_provider), None) => {
4056                let session_key = self.m4a_session_key(instance_id);
4057                self.m4a_session_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key)?
4058            }
4059            (SessionMode::Pty, Some(_), None, _) => {
4060                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4061                environment_overlay
4062            }
4063            _ => environment_overlay,
4064        };
4065        let mut instance_overlay = environment_overlay
4066            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4067            .transpose()?
4068            .flatten();
4069        Ok(PreparedSpawn {
4070            instance_overlay,
4071            environment_selection,
4072            materialization_guard,
4073            harness_mcp_overlay,
4074            #[cfg(feature = "dig2-station-probe")]
4075            browser_station_lease_guard,
4076            materialization_id,
4077        })
4078    }
4079}
4080
4081struct SessionRecords {
4082    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4083}
4084
4085struct SpawnIdempotencyCache {
4086    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4087}
4088
4089struct SpawnIdempotencyEntry {
4090    value: SpawnIdempotencyValue,
4091    expires_at: Instant,
4092}
4093
4094enum SpawnIdempotencyValue {
4095    Standard {
4096        spec: SpawnSpec,
4097        result: Result<ResolvedSpawnReceipt, NodeFailure>,
4098    },
4099    Managed {
4100        request: ManagedWorktreeSpawnRequest,
4101        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4102    },
4103    HarnessMcp,
4104}
4105
4106enum DurableManagedSpawnReplayDecision {
4107    Reserved,
4108    PendingLinked,
4109    Committed(ManagedWorktreeSpawnReceipt),
4110}
4111
4112impl SpawnIdempotencyValue {
4113    fn references_context(&self, context_id: &SpawnContextId) -> bool {
4114        match self {
4115            Self::Standard { result, .. } => result
4116                .as_ref()
4117                .ok()
4118                .and_then(|receipt| receipt.context.as_ref()),
4119            Self::Managed { result, .. } => result
4120                .as_ref()
4121                .ok()
4122                .and_then(|receipt| receipt.spawn.context.as_ref()),
4123            Self::HarnessMcp => None,
4124        }
4125        .is_some_and(|receipt| &receipt.id == context_id)
4126    }
4127}
4128
4129#[derive(Clone, Copy)]
4130struct ControllerLease {
4131    connection_id: u64,
4132    expires_at: Instant,
4133}
4134
4135struct NodeEventHistory {
4136    last_sequence: u64,
4137    replay_floor_sequence: u64,
4138    events: VecDeque<NodeEventEnvelope>,
4139    record_providers: BTreeMap<SessionRecordId, AgentId>,
4140    removed_record_providers: BTreeMap<u64, AgentId>,
4141}
4142
4143impl NodeEventHistory {
4144    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4145        Self {
4146            last_sequence: 0,
4147            replay_floor_sequence: 1,
4148            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4149            record_providers,
4150            removed_record_providers: BTreeMap::new(),
4151        }
4152    }
4153}
4154
4155impl NodeShared {
4156    /// A node-spawned grok PTY session gets its own leader socket, so its agent backend runs
4157    /// as a descendant of this session's process (with the session's own environment) and
4158    /// never attaches to an operator's shared leader. Same grok home, same login.
4159    fn session_grok_leader_socket(
4160        &self,
4161        provider: &AgentId,
4162        mode: SessionMode,
4163        instance_id: AgentInstanceId,
4164    ) -> Option<std::path::PathBuf> {
4165        if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4166            return None;
4167        }
4168        let root = self
4169            .m4a_accounts
4170            .as_ref()
4171            .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4172            .unwrap_or_else(std::env::temp_dir);
4173        let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4174        if let Some(previous) = self
4175            .grok_leaders
4176            .lock()
4177            .unwrap_or_else(|p| p.into_inner())
4178            .insert(instance_id, socket.clone())
4179        {
4180            crate::grok_leader::retire(&previous);
4181        }
4182        Some(socket)
4183    }
4184
4185    /// Stops this session's own grok leader, if it had one. Never touches any other leader.
4186    fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4187        let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4188        if let Some(socket) = socket {
4189            crate::grok_leader::retire(&socket);
4190        }
4191    }
4192
4193    fn new_with_incarnation(
4194        handle: Gate4AgentHandle,
4195        access_token: String,
4196        node_id: NodeId,
4197        incarnation_id: NodeIncarnationId,
4198        workspaces: Vec<WorkspaceConfig>,
4199        enabled_providers: Vec<AgentId>,
4200        provider_runtime_statuses: ProviderRuntimeStatuses,
4201        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4202        provider_contracts: Vec<ProviderContractSupport>,
4203        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4204        spawn_profiles: SpawnProfileRegistry,
4205        native_launch_profile_control: Option<NativeLaunchProfileControl>,
4206        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4207        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4208        state_path: Option<PathBuf>,
4209        records: Vec<ManagedSessionRecord>,
4210        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4211        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4212        materializations: Vec<MaterializationOwnershipRecord>,
4213        persistence_warning: Option<String>,
4214        input_settle_timeout_ms: u64,
4215    ) -> Self {
4216        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4217        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4218        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4219        let (harness_mcp_proxy_event_tx, _) =
4220            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4221        let record_providers = records
4222            .iter()
4223            .map(|record| (record.record_id.clone(), record.provider.clone()))
4224            .collect();
4225        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4226            .iter()
4227            .map(|workspace| {
4228                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4229            })
4230            .collect();
4231        for lease in &managed_worktrees {
4232            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4233        }
4234        let managed_worktree_profiles = workspaces.iter()
4235            .map(|workspace| (
4236                workspace.workspace_id.clone(),
4237                workspace.managed_worktree_profiles.clone(),
4238            ))
4239            .collect();
4240        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4241            managed_worktrees,
4242            managed_worktree_tombstones,
4243        ).expect("durable managed worktree registry was validated while loading");
4244        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4245        managed_worktrees.reattach_record_holders(
4246            &managed_worktree_record_holders(&records),
4247            unix_time_ms(),
4248        );
4249        Self {
4250            handle,
4251            access_token,
4252            node_id,
4253            incarnation_id,
4254            started_at_unix_ms: std::time::SystemTime::now()
4255                .duration_since(std::time::UNIX_EPOCH)
4256                .unwrap_or_default()
4257                .as_millis()
4258                .min(u64::MAX as u128) as u64,
4259            workspaces: RwLock::new(
4260                workspaces
4261                    .into_iter()
4262                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4263                    .collect(),
4264            ),
4265            worktree_service_modes,
4266            managed_worktree_profiles,
4267            managed_worktrees: Mutex::new(managed_worktrees),
4268            enabled_providers,
4269            provider_runtime_statuses,
4270            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4271            provider_runtime_monitor,
4272            provider_contracts,
4273            provider_adapter_contracts,
4274            spawn_profiles,
4275            environment_profiles: RwLock::new(BTreeMap::new()),
4276            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4277            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4278            #[cfg(feature = "dig2-station-probe")]
4279            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4280            bundle_catalog: RwLock::new(BundleCatalog::default()),
4281            delivery_store: Mutex::new(None),
4282            context_pack_store: Mutex::new(None),
4283            context_catalog: RwLock::new(ContextPackCatalog::default()),
4284            native_launch_profile_control,
4285            harness_mcp_registry: None,
4286            native_session_catalog,
4287            session_environment_materializer,
4288            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4289            m4a_live: Mutex::new(BTreeMap::new()),
4290            m4a_keys: Mutex::new(BTreeMap::new()),
4291            m4a_reuse_keys: Mutex::new(BTreeMap::new()),
4292            grok_leaders: Mutex::new(BTreeMap::new()),
4293            #[cfg(unix)]
4294            signer_proxy: std::sync::OnceLock::new(),
4295            materializations: Mutex::new(
4296                materializations
4297                    .into_iter()
4298                    .map(|record| (record.id().clone(), record))
4299                    .collect(),
4300            ),
4301            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4302                entries: BTreeMap::new(),
4303            }),
4304            managed_spawn_replays: Mutex::new(BTreeMap::new()),
4305            controller: Mutex::new(None),
4306            provisional_spawns: Mutex::new(BTreeMap::new()),
4307            pending_acp_options: Mutex::new(BTreeMap::new()),
4308            history: Mutex::new(NodeEventHistory::new(record_providers)),
4309            event_tx,
4310            terminal_event_tx,
4311            agent_stream_event_tx,
4312            harness_mcp_proxy_event_tx,
4313            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4314            pty_input_wake: tokio::sync::Notify::new(),
4315            pty_input_marks: Mutex::new(BTreeMap::new()),
4316            next_connection_id: AtomicU64::new(1),
4317            next_instance_id: AtomicU64::new(1),
4318            next_command_id: AtomicU64::new(1),
4319            shutdown: AtomicBool::new(false),
4320            shutdown_notify: Notify::new(),
4321            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4322            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4323            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4324            mutation_gate: AsyncMutex::new(()),
4325            session_bindings: Mutex::new(BTreeMap::new()),
4326            session_records: Mutex::new(SessionRecords {
4327                records: records
4328                    .into_iter()
4329                    .map(|record| (record.record_id.clone(), record))
4330                    .collect(),
4331            }),
4332            state_path,
4333            persistence_error: RwLock::new(
4334                persistence_warning
4335                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
4336            ),
4337            state_transaction: Mutex::new(()),
4338            input_settle_timeout_ms,
4339            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4340            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4341            shell_efficiency_profile: OnceLock::new(),
4342            native_session_gauge: AtomicUsize::new(0),
4343            connection_loop_iterations: AtomicU64::new(0),
4344            connection_events_sent: AtomicU64::new(0),
4345            rejected_commands_total: AtomicU64::new(0),
4346            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4347            drive_loop_iterations_total: AtomicU64::new(0),
4348            drive_loop_iterations_idle: AtomicU64::new(0),
4349            session_record_retention: SessionRecordRetentionConfig::default(),
4350            retired_records_total: AtomicU64::new(0),
4351            #[cfg(feature = "fixture")]
4352            fixture_semantic_hook_policy: false,
4353            #[cfg(feature = "fixture")]
4354            fixture_spawn_managed_worktree_v2_failure_probe:
4355                SpawnManagedWorktreeV2FailureProbe::default(),
4356        }
4357    }
4358
4359    #[cfg(test)]
4360    fn new(
4361        handle: Gate4AgentHandle,
4362        access_token: String,
4363        node_id: NodeId,
4364        workspaces: Vec<WorkspaceConfig>,
4365        enabled_providers: Vec<AgentId>,
4366    ) -> Self {
4367        Self::new_with_incarnation(
4368            handle,
4369            access_token,
4370            node_id,
4371            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4372            workspaces,
4373            enabled_providers,
4374            ProviderRuntimeStatuses::default(),
4375            None,
4376            Vec::new(),
4377            Vec::new(),
4378            SpawnProfileRegistry::default(),
4379            None,
4380            None,
4381            None,
4382            None,
4383            Vec::new(),
4384            Vec::new(),
4385            Vec::new(),
4386            Vec::new(),
4387            None,
4388            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4389        )
4390    }
4391
4392    fn persistence_error(&self) -> Option<String> {
4393        self.persistence_error
4394            .read()
4395            .unwrap_or_else(|poisoned| poisoned.into_inner())
4396            .clone()
4397    }
4398
4399    async fn admit_provider_runtime(
4400        &self,
4401        provider: &AgentId,
4402        requirement: ProviderRuntimeRequirement,
4403    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4404        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4405            let pipe_transport = monitor.supports_pipe_transport(provider);
4406            let acp_transport = monitor.supports_acp_transport(provider);
4407            let provider = provider.clone();
4408            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4409            let (status, admission) = timeout(
4410                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4411                refresh,
4412            )
4413            .await
4414            .map_err(|_| failure(
4415                NodeFailureCode::BackendBusy,
4416                "provider runtime probe exceeded its bounded deadline",
4417            ))?
4418            .map_err(|_| failure(
4419                NodeFailureCode::BackendOperationFailed,
4420                "provider runtime probe task failed",
4421            ))?;
4422            if let Some(status) = status {
4423                self
4424                    .provider_runtime_status_updates
4425                    .write()
4426                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4427                    .insert(status.provider().clone(), status);
4428            }
4429            admission.and_then(|policy| {
4430                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4431            })
4432        } else {
4433            // No live monitor means no catalog to consult -- there is
4434            // nothing here to confirm this provider declares a Pipe or ACP
4435            // transport, so Inline/Acp are rejected the same conservative
4436            // way an undeclared provider always was, rather than admitted
4437            // on trust.
4438            crate::provider_runtime::admit_status(
4439                &self.provider_runtime_statuses,
4440                provider,
4441                false,
4442                false,
4443                requirement,
4444            )
4445        };
4446        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4447        admission.map_err(|error| match error {
4448            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4449                NodeFailureCode::BackendOperationFailed,
4450                "provider launcher is unavailable",
4451            ),
4452            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4453                NodeFailureCode::UnsupportedCapability,
4454                "provider semantic capability is not verified",
4455            ),
4456            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4457                NodeFailureCode::BackendBusy,
4458                "provider runtime probe is already in progress",
4459            ),
4460        })
4461    }
4462
4463    /// Whether the catalog declares a Pipe transport for `provider` --
4464    /// the fact `require_policy` needs to admit or reject
4465    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4466    /// catalog to consult, so this stays conservative and rejects, matching
4467    /// `admit_provider_runtime`'s own no-monitor branch.
4468    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4469        match self.provider_runtime_monitor.as_deref() {
4470            Some(monitor) => monitor.supports_pipe_transport(provider),
4471            None => false,
4472        }
4473    }
4474
4475    /// Whether the catalog declares an ACP transport for `provider` -- the
4476    /// fact `require_policy` needs to admit or reject
4477    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4478    /// `provider_supports_pipe_transport` exactly, including the
4479    /// conservative no-monitor default.
4480    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4481        match self.provider_runtime_monitor.as_deref() {
4482            Some(monitor) => monitor.supports_acp_transport(provider),
4483            None => false,
4484        }
4485    }
4486
4487    /// Grants hook ingestion to a provider whose catalog entry declares a
4488    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4489    ///
4490    /// `admit_status`'s `RawPassthrough` arm hands back
4491    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4492    /// correct for everything it was written to answer, and wrong for hooks
4493    /// specifically. A hook event is not inferred from terminal behaviour,
4494    /// so it has nothing to do with whether a launcher probe verified this
4495    /// CLI version's PTY contract; the authority is the catalog declaring
4496    /// the adapter plus the ingress authenticating the route. Without this
4497    /// the node installs a provider's hooks, the CLI faithfully calls them,
4498    /// and every event is refused -- which is exactly the state grok was in.
4499    ///
4500    /// Applied to both admission paths so the monitor's own derivation and
4501    /// this coarse fallback cannot disagree about the same provider.
4502    fn admit_declared_hook_semantics(
4503        &self,
4504        provider: &AgentId,
4505        policy: ProviderRuntimePolicy,
4506    ) -> ProviderRuntimePolicy {
4507        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4508            return policy;
4509        }
4510        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4511            contract.provider == *provider && contract.family == AdapterFamily::Hook
4512        });
4513        if !declares_hook {
4514            return policy;
4515        }
4516        ProviderRuntimePolicy::new(
4517            policy.raw_pty_lifecycle,
4518            policy.semantic_readiness,
4519            policy.structured_prompt,
4520            policy.provider_session_identity,
4521            policy.semantic_resume,
4522            true,
4523        )
4524        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4525    }
4526
4527    /// Station network allowlist → provider launch overlay argv.
4528    ///
4529    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4530    ///   `provider_native` under Moderate (PTY or ACP).
4531    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4532    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4533    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4534    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4535    ///   no overlay.
4536    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4537    ///   empty overlay when no `provider_native`; invented native keys already
4538    ///   refuse at load / resolve.
4539    ///
4540    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4541    fn resolve_network_allowlist_overlay_args(
4542        &self,
4543        provider: &AgentId,
4544        mode: SessionMode,
4545        approval_level: ApprovalLevel,
4546        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4547        provider_home: Option<&Path>,
4548    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4549        let Some(allowlist_id) = network_allowlist else {
4550            return Ok(Vec::new());
4551        };
4552        let catalog = self
4553            .network_allowlist_catalog
4554            .read()
4555            .unwrap_or_else(|poisoned| poisoned.into_inner());
4556        let Some(entry) = catalog.get(allowlist_id) else {
4557            return Err(failure(
4558                NodeFailureCode::UnknownNetworkAllowlist,
4559                "spawn network allowlist is unavailable on this node",
4560            ));
4561        };
4562
4563        // Claude: settings-shaped overlay from permits (not provider_native).
4564        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4565            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4566                |message| {
4567                    failure(
4568                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4569                        &message,
4570                    )
4571                },
4572            )?;
4573            if !matches!(mode, SessionMode::Pty) {
4574                return Err(failure(
4575                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4576                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4577                ));
4578            }
4579            let Some(home) = provider_home else {
4580                return Err(failure(
4581                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4582                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4583                ));
4584            };
4585            let domains =
4586                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4587            if domains.is_empty() {
4588                return Err(failure(
4589                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4590                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4591                ));
4592            }
4593            let settings_path = home.join(
4594                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4595            );
4596            let json =
4597                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4598            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4599                |_| {
4600                    failure(
4601                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4602                        "Claude station network settings sidecar could not be written under ProviderHome",
4603                    )
4604                },
4605            )?;
4606            let overlay =
4607                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4608            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4609        }
4610
4611        let Some(native) = entry.provider_native.as_ref() else {
4612            return Ok(Vec::new());
4613        };
4614        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4615            provider.as_str(),
4616            approval_level,
4617            native,
4618        )
4619        .map_err(|message| {
4620            failure(
4621                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4622                &message,
4623            )
4624        })?;
4625        if overlay.is_empty() {
4626            return Ok(Vec::new());
4627        }
4628        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4629            return Err(failure(
4630                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4631                "Codex network_access -c overlay requires PTY or ACP transport",
4632            ));
4633        }
4634        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4635    }
4636
4637    /// Backward-compatible alias used by Codex-focused unit tests.
4638    fn resolve_codex_network_access_overlay_args(
4639        &self,
4640        provider: &AgentId,
4641        mode: SessionMode,
4642        approval_level: ApprovalLevel,
4643        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4644    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4645        self.resolve_network_allowlist_overlay_args(
4646            provider,
4647            mode,
4648            approval_level,
4649            network_allowlist,
4650            None,
4651        )
4652    }
4653
4654    fn resolve_environment_profile(
4655        &self,
4656        resolved: &ResolvedSpawnSpec,
4657    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4658        // Station network allowlist catalog enforce (plan
4659        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4660        // catalog — unknown ids refuse rather than silent ambient. Runs even
4661        // when no environment_profile_id is present so a bare allowlist still
4662        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4663        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4664            let catalog = self
4665                .network_allowlist_catalog
4666                .read()
4667                .unwrap_or_else(|poisoned| poisoned.into_inner());
4668            let Some(entry) = catalog.get(allowlist_id) else {
4669                return Err(failure(
4670                    NodeFailureCode::UnknownNetworkAllowlist,
4671                    "spawn network allowlist is unavailable on this node",
4672                ));
4673            };
4674            // Track B: refuse provider-native mappings this provider cannot
4675            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4676            if let Some(native) = entry.provider_native.as_ref() {
4677                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4678                    resolved.provider.as_str(),
4679                    resolved.approval_level,
4680                    native,
4681                ) {
4682                    return Err(failure(
4683                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4684                        &message,
4685                    ));
4686                }
4687            }
4688        }
4689        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4690        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4691        // cheap named-pipe path reachability must succeed (Windows
4692        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4693        // NodeFailureCode. Runs even without environment_profile_id (same
4694        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4695        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4696        // acquired later in spawn_session_with_deadline (after instance id).
4697        #[cfg(feature = "dig2-station-probe")]
4698        if resolved.browser_profile_id.is_some() {
4699            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4700            match probe_station_reachable() {
4701                Ok(()) => {}
4702                Err(StationProbeError::UnsupportedPlatform)
4703                | Err(StationProbeError::InvalidPipeSuffix) => {
4704                    return Err(failure(
4705                        NodeFailureCode::BrowserStationProbeUnavailable,
4706                        "dig2browser station probe unavailable on this platform",
4707                    ));
4708                }
4709                Err(StationProbeError::Unreachable) => {
4710                    return Err(failure(
4711                        NodeFailureCode::BrowserStationUnreachable,
4712                        "dig2browser station named-pipe is unreachable",
4713                    ));
4714                }
4715            }
4716        }
4717        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4718            return Ok(None);
4719        };
4720        let profiles = self
4721            .environment_profiles
4722            .read()
4723            .unwrap_or_else(|poisoned| poisoned.into_inner());
4724        let binding = profiles.get(profile_id).ok_or_else(|| {
4725            failure(
4726                NodeFailureCode::UnknownEnvironmentProfile,
4727                "spawn environment profile is unavailable on this node",
4728            )
4729        })?;
4730        if binding.provider != resolved.provider
4731            || binding.native_profile_id(resolved.mode).is_none()
4732        {
4733            return Err(failure(
4734                NodeFailureCode::EnvironmentProfileBindingMismatch,
4735                "spawn environment profile does not match the provider and mode",
4736            ));
4737        }
4738        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4739        // after catalog + optional dig2 probe above. Empty/whitespace ids
4740        // already refuse at Spawn*Id construction.
4741        Ok(Some(ResolvedEnvironmentProfileReceipt {
4742            profile_id: binding.id.clone(),
4743            profile_revision: binding.revision.clone(),
4744            network_allowlist: resolved.network_allowlist.clone(),
4745            browser_profile_id: resolved.browser_profile_id.clone(),
4746        }))
4747    }
4748
4749    fn resolve_bundle(
4750        &self,
4751        resolved: &ResolvedSpawnSpec,
4752        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4753    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4754        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4755            return Ok(None);
4756        };
4757        let catalog = self.bundle_catalog
4758            .read()
4759            .unwrap_or_else(|poisoned| poisoned.into_inner());
4760        let bundle = catalog.get(bundle_id).ok_or_else(|| {
4761                failure(
4762                    NodeFailureCode::UnknownBundle,
4763                    "spawn bundle is unavailable on this node",
4764                )
4765            })?;
4766        self.bundle_layout(
4767            &resolved.provider,
4768            resolved.mode,
4769            environment_profile,
4770            bundle,
4771        )?;
4772        Ok(Some(bundle.receipt()))
4773    }
4774
4775    fn begin_delivery_stage(
4776        &self,
4777        manifest: DeliveryBundleManifestV2,
4778    ) -> Result<NodeResponse, NodeFailure> {
4779        let mut store = self
4780            .delivery_store
4781            .lock()
4782            .unwrap_or_else(|poisoned| poisoned.into_inner());
4783        let store = store.as_mut().ok_or_else(|| {
4784            failure(
4785                NodeFailureCode::DeliveryStageStorageFailed,
4786                "node delivery store is unavailable",
4787            )
4788        })?;
4789        let begun = store.begin(manifest).map_err(delivery_failure)?;
4790        Ok(NodeResponse::DeliveryStageBegun {
4791            stage_id: begun.stage_id,
4792            manifest_digest: begun.manifest_digest,
4793            missing_blobs: begun.missing_blobs,
4794        })
4795    }
4796
4797    fn put_delivery_blob_chunk(
4798        &self,
4799        stage_id: DeliveryStageId,
4800        blob_digest: DeliveryBlobDigestV1,
4801        offset: u64,
4802        chunk_hex: DeliveryBlobChunkHexV1,
4803    ) -> Result<NodeResponse, NodeFailure> {
4804        let mut store = self
4805            .delivery_store
4806            .lock()
4807            .unwrap_or_else(|poisoned| poisoned.into_inner());
4808        let store = store.as_mut().ok_or_else(|| {
4809            failure(
4810                NodeFailureCode::DeliveryStageStorageFailed,
4811                "node delivery store is unavailable",
4812            )
4813        })?;
4814        let accepted = store
4815            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4816            .map_err(delivery_failure)?;
4817        Ok(NodeResponse::DeliveryBlobChunkAccepted {
4818            stage_id: accepted.stage_id,
4819            blob_digest: accepted.blob_digest,
4820            next_offset: accepted.next_offset,
4821        })
4822    }
4823
4824    fn commit_delivery_stage(
4825        &self,
4826        stage_id: DeliveryStageId,
4827    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4828        let mut store = self
4829            .delivery_store
4830            .lock()
4831            .unwrap_or_else(|poisoned| poisoned.into_inner());
4832        let store = store.as_mut().ok_or_else(|| {
4833            failure(
4834                NodeFailureCode::DeliveryStageStorageFailed,
4835                "node delivery store is unavailable",
4836            )
4837        })?;
4838        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4839        let mut catalog = self
4840            .bundle_catalog
4841            .write()
4842            .unwrap_or_else(|poisoned| poisoned.into_inner());
4843        let mut next_catalog = catalog.clone();
4844        next_catalog
4845            .insert_idempotent(prepared.bundle().clone())
4846            .map_err(|_| {
4847                failure(
4848                    NodeFailureCode::DeliveryStageConflict,
4849                    "delivery bundle identity conflicts with installed content",
4850                )
4851            })?;
4852        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4853        *catalog = next_catalog;
4854        Ok(receipt)
4855    }
4856
4857    fn abort_delivery_stage(
4858        &self,
4859        stage_id: &DeliveryStageId,
4860    ) -> Result<(), NodeFailure> {
4861        let mut store = self
4862            .delivery_store
4863            .lock()
4864            .unwrap_or_else(|poisoned| poisoned.into_inner());
4865        let store = store.as_mut().ok_or_else(|| {
4866            failure(
4867                NodeFailureCode::DeliveryStageStorageFailed,
4868                "node delivery store is unavailable",
4869            )
4870        })?;
4871        store.abort(stage_id).map_err(delivery_failure)
4872    }
4873
4874    fn resolve_context(
4875        &self,
4876        resolved: &ResolvedSpawnSpec,
4877    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4878        let Some(context_id) = resolved.context_id.as_ref() else {
4879            return Ok(None);
4880        };
4881        self.context_catalog
4882            .read()
4883            .unwrap_or_else(|poisoned| poisoned.into_inner())
4884            .get(context_id)
4885            .map(|context| context.receipt().clone())
4886            .ok_or_else(|| {
4887                failure(
4888                    NodeFailureCode::UnknownContextPack,
4889                    "spawn context pack is unavailable on this node",
4890                )
4891            })
4892            .map(Some)
4893    }
4894
4895    fn bundle_layout(
4896        &self,
4897        provider: &AgentId,
4898        mode: SessionMode,
4899        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4900        bundle: &NodeBundle,
4901    ) -> Result<BundleProviderLayout, NodeFailure> {
4902        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4903            failure(
4904                NodeFailureCode::BundleBindingMismatch,
4905                "spawn bundle does not match the provider and mode",
4906            )
4907        })?;
4908        if layout != BundleProviderLayout::Codex {
4909            return Ok(layout);
4910        }
4911        let profile = environment_profile.and_then(|receipt| {
4912            let profiles = self
4913                .environment_profiles
4914                .read()
4915                .unwrap_or_else(|poisoned| poisoned.into_inner());
4916            let binding = profiles.get(&receipt.profile_id)?;
4917            if binding.revision != receipt.profile_revision
4918                || &binding.provider != provider
4919                || binding.native_profile_id(mode).is_none()
4920            {
4921                return None;
4922            }
4923            self.environment_materialization_profiles
4924                .read()
4925                .unwrap_or_else(|poisoned| poisoned.into_inner())
4926                .get(&receipt.profile_id)
4927                .cloned()
4928        });
4929        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4930            return Err(failure(
4931                NodeFailureCode::BundleBindingMismatch,
4932                "Codex bundle requires an exact isolated CODEX_HOME profile",
4933            ));
4934        }
4935        Ok(layout)
4936    }
4937
4938    fn select_environment_profile(
4939        &self,
4940        instance_id: AgentInstanceId,
4941        provider: &AgentId,
4942        mode: SessionMode,
4943        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4944    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4945        let Some(environment_profile) = environment_profile else {
4946            return Ok(None);
4947        };
4948        let native_profile_id = {
4949            let profiles = self
4950                .environment_profiles
4951                .read()
4952                .unwrap_or_else(|poisoned| poisoned.into_inner());
4953            let binding = profiles
4954                .get(&environment_profile.profile_id)
4955                .ok_or_else(|| {
4956                    failure(
4957                        NodeFailureCode::UnknownEnvironmentProfile,
4958                        "session environment profile is unavailable on this node",
4959                    )
4960                })?;
4961            if &binding.revision != &environment_profile.profile_revision
4962                || &binding.provider != provider
4963            {
4964                return Err(failure(
4965                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4966                    "session environment profile revision or provider changed",
4967                ));
4968            }
4969            binding.native_profile_id(mode).cloned().ok_or_else(|| {
4970                failure(
4971                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4972                    "session environment profile does not support this mode",
4973                )
4974            })?
4975        };
4976        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4977            failure(
4978                NodeFailureCode::BackendOperationFailed,
4979                "native environment profile controller is unavailable",
4980            )
4981        })?;
4982        control
4983            .select_native_launch_profile(instance_id, native_profile_id)
4984            .map_err(|_| {
4985                failure(
4986                    NodeFailureCode::BackendOperationFailed,
4987                    "native environment profile selection failed",
4988                )
4989            })?;
4990        Ok(Some(NativeEnvironmentSelectionGuard {
4991            control: Some(control),
4992            instance_id,
4993        }))
4994    }
4995
4996    fn install_prepared_launch_overlay(
4997        &self,
4998        instance_id: AgentInstanceId,
4999        overlay: PreparedNativeLaunchOverlay,
5000    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
5001        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5002            failure(
5003                NodeFailureCode::BackendOperationFailed,
5004                "native launch profile controller is unavailable",
5005            )
5006        })?;
5007        match overlay {
5008            PreparedNativeLaunchOverlay::Environment(overlay) => {
5009                control
5010                    .install_native_launch_environment_overlay(instance_id, overlay)
5011                    .map_err(|_| failure(
5012                        NodeFailureCode::BackendOperationFailed,
5013                        "session environment overlay installation failed",
5014                    ))?;
5015                Ok(None)
5016            }
5017            PreparedNativeLaunchOverlay::Instance(overlay) => {
5018                control
5019                    .install_native_instance_launch_overlay(instance_id, overlay)
5020                    .map_err(|_| failure(
5021                        NodeFailureCode::BundleBindingMismatch,
5022                        "session bundle launch overlay installation failed",
5023                    ))?;
5024                Ok(Some(NativeInstanceOverlayGuard {
5025                    control: Some(control),
5026                    instance_id,
5027                }))
5028            }
5029        }
5030    }
5031
5032    fn environment_materialization_profile(
5033        &self,
5034        environment_profile: &ResolvedEnvironmentProfileReceipt,
5035    ) -> Option<NodeSessionMaterializationProfile> {
5036        self.environment_materialization_profiles
5037            .read()
5038            .unwrap_or_else(|poisoned| poisoned.into_inner())
5039            .get(&environment_profile.profile_id)
5040            .cloned()
5041    }
5042
5043    fn materialization_profile(
5044        &self,
5045        provider: &AgentId,
5046        mode: SessionMode,
5047        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5048        bundle: Option<&ResolvedBundleReceipt>,
5049        context: Option<&ResolvedContextPackReceipt>,
5050    ) -> Result<Option<(
5051        NodeSessionMaterializationProfile,
5052        bool,
5053        Option<BundleProviderLayout>,
5054    )>, NodeFailure> {
5055        let environment = environment_profile
5056            .and_then(|receipt| self.environment_materialization_profile(receipt));
5057        let (bundle, bundle_layout) = match bundle {
5058            Some(receipt) => {
5059                let catalog = self.bundle_catalog.read()
5060                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5061                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5062                    NodeFailureCode::UnknownBundle,
5063                    "session bundle is unavailable on this node",
5064                ))?;
5065                if installed.receipt() != *receipt {
5066                    return Err(failure(
5067                        NodeFailureCode::BundleBindingMismatch,
5068                        "session bundle revision or digest changed",
5069                    ));
5070                }
5071                let layout = self.bundle_layout(
5072                    provider,
5073                    mode,
5074                    environment_profile,
5075                    installed,
5076                )?;
5077                (Some(installed.clone()), Some(layout))
5078            }
5079            None => (None, None),
5080        };
5081        let has_environment_overlay = environment.is_some() || context.is_some();
5082        let profile = match (environment, bundle.as_ref(), bundle_layout) {
5083            (None, None, None) => None,
5084            (Some(profile), None, None) => Some(Ok(profile)),
5085            (None, Some(bundle), Some(layout)) => {
5086                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5087            }
5088            (Some(profile), Some(bundle), Some(layout)) => {
5089                Some(profile.with_bundle(bundle, layout))
5090            }
5091            _ => {
5092                return Err(failure(
5093                    NodeFailureCode::BundleBindingMismatch,
5094                    "session bundle provider layout is unavailable",
5095                ));
5096            }
5097        };
5098        let profile = profile
5099            .transpose()
5100            .map_err(|_| failure(
5101                NodeFailureCode::BundleMaterializationFailed,
5102                "session bundle materialization profile is invalid",
5103            ))?;
5104        let profile = match (profile, context) {
5105            (Some(profile), Some(receipt)) => {
5106                let catalog = self.context_catalog
5107                    .read()
5108                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5109                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5110                    NodeFailureCode::UnknownContextPack,
5111                    "session context pack is unavailable on this node",
5112                ))?;
5113                if pack.receipt() != receipt {
5114                    return Err(failure(
5115                        NodeFailureCode::ContextPackMaterializationFailed,
5116                        "session context pack identity changed",
5117                    ));
5118                }
5119                profile.with_context(pack)
5120            }
5121            (None, Some(receipt)) => {
5122                let catalog = self.context_catalog
5123                    .read()
5124                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5125                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5126                    NodeFailureCode::UnknownContextPack,
5127                    "session context pack is unavailable on this node",
5128                ))?;
5129                if pack.receipt() != receipt {
5130                    return Err(failure(
5131                        NodeFailureCode::ContextPackMaterializationFailed,
5132                        "session context pack identity changed",
5133                    ));
5134                }
5135                NodeSessionMaterializationProfile::from_context(pack)
5136            }
5137            (Some(profile), None) => Ok(profile),
5138            (None, None) => return Ok(None),
5139        }
5140        .map_err(|_| failure(
5141            NodeFailureCode::ContextPackMaterializationFailed,
5142            "session context pack materialization profile is invalid",
5143        ))?;
5144        Ok(Some((profile, has_environment_overlay, bundle_layout)))
5145    }
5146
5147    fn prepare_session_materialization<'a>(
5148        &'a self,
5149        address: &SessionAddress,
5150        provider: &AgentId,
5151        mode: SessionMode,
5152        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5153        bundle: Option<&ResolvedBundleReceipt>,
5154        context: Option<&ResolvedContextPackReceipt>,
5155        managed_lease_id: Option<ManagedWorktreeLeaseId>,
5156    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5157        let Some((profile, has_environment_overlay, bundle_layout)) =
5158            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5159        else {
5160            return Ok(None);
5161        };
5162        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5163            failure(
5164                NodeFailureCode::BackendOperationFailed,
5165                "session environment materializer is unavailable",
5166            )
5167        })?;
5168        let materialization_failure_code = if context.is_some() {
5169            NodeFailureCode::ContextPackMaterializationFailed
5170        } else if bundle.is_some() {
5171            NodeFailureCode::BundleMaterializationFailed
5172        } else {
5173            NodeFailureCode::BackendOperationFailed
5174        };
5175        let id = MaterializationId::new(format!(
5176            "mat-{}-{}-{}",
5177            self.incarnation_id,
5178            address.session.instance_id.0,
5179            address.session.generation.0,
5180        ))
5181        .map_err(|_| {
5182            failure(
5183                NodeFailureCode::BackendOperationFailed,
5184                "session environment materialization identity failed",
5185            )
5186        })?;
5187        let owner = MaterializationOwner::Session {
5188            incarnation_id: self.incarnation_id,
5189            instance_id: address.session.instance_id,
5190            generation: address.session.generation,
5191        };
5192        let mut ownership = materializer
5193            .begin(
5194                id.clone(),
5195                environment_profile.cloned(),
5196                bundle.cloned(),
5197                context.cloned(),
5198                owner,
5199                managed_lease_id,
5200                &profile,
5201                unix_time_ms(),
5202            )
5203            .map_err(|_| {
5204                failure(
5205                    materialization_failure_code,
5206                    "session materialization preparation failed",
5207                )
5208            })?;
5209        {
5210            let _transaction = self
5211                .state_transaction
5212                .lock()
5213                .unwrap_or_else(|poisoned| poisoned.into_inner());
5214            let duplicate = self
5215                .materializations
5216                .lock()
5217                .unwrap_or_else(|poisoned| poisoned.into_inner())
5218                .insert(id.clone(), ownership.clone());
5219            if duplicate.is_some() {
5220                return Err(failure(
5221                    NodeFailureCode::BackendOperationFailed,
5222                    "session environment materialization identity conflict",
5223                ));
5224            }
5225            if let Err(error) = self.persist_state_locked() {
5226                self.materializations
5227                    .lock()
5228                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5229                    .remove(&id);
5230                return Err(persistence_failure(error));
5231            }
5232        }
5233        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5234            Ok(environment) => environment,
5235            Err(_) => {
5236                let transaction = self
5237                    .state_transaction
5238                    .lock()
5239                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5240                self.materializations
5241                    .lock()
5242                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5243                    .insert(id.clone(), ownership.clone());
5244                if self.persist_state_locked().is_err() {
5245                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5246                }
5247                drop(transaction);
5248                if ownership.state() == MaterializationState::CleanupRequired {
5249                    let _ = self.cleanup_materialization(&id);
5250                }
5251                return Err(failure(
5252                    materialization_failure_code,
5253                    "session materialization failed",
5254                ));
5255            }
5256        };
5257        {
5258            let _transaction = self
5259                .state_transaction
5260                .lock()
5261                .unwrap_or_else(|poisoned| poisoned.into_inner());
5262            self.materializations
5263                .lock()
5264                .unwrap_or_else(|poisoned| poisoned.into_inner())
5265                .insert(id.clone(), ownership.clone());
5266            if let Err(error) = self.persist_state_locked() {
5267                drop(_transaction);
5268                let _ = self.cleanup_materialization(&id);
5269                return Err(persistence_failure(error));
5270            }
5271        }
5272        let guard = SessionMaterializationGuard {
5273            shared: self,
5274            id: Some(id),
5275            provider_home: Some(ownership.provider_home().to_path_buf()),
5276        };
5277        let bundle_arguments = match bundle {
5278            Some(receipt) => {
5279                let catalog = self.bundle_catalog.read()
5280                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5281                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5282                    NodeFailureCode::UnknownBundle,
5283                    "session bundle is unavailable on this node",
5284                ))?;
5285                if installed.receipt() != *receipt {
5286                    return Err(failure(
5287                        NodeFailureCode::BundleBindingMismatch,
5288                        "session bundle revision or digest changed",
5289                    ));
5290                }
5291                let layout = bundle_layout.ok_or_else(|| failure(
5292                    NodeFailureCode::BundleBindingMismatch,
5293                    "materialized bundle provider layout is unavailable",
5294                ))?;
5295                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5296                    failure(
5297                        NodeFailureCode::BundleMaterializationFailed,
5298                        "session bundle byte revalidation failed",
5299                    )
5300                })?;
5301                Some(bundle_launch_arguments(
5302                    layout,
5303                    ownership.bundle_root(),
5304                    ownership.provider_home(),
5305                ).map_err(|_| failure(
5306                    NodeFailureCode::BundleBindingMismatch,
5307                    "session bundle launch binding is invalid",
5308                ))?)
5309            }
5310            None => None,
5311        };
5312        if let Some(receipt) = context {
5313            materializer
5314                .revalidate_context(&ownership, receipt)
5315                .map_err(|_| failure(
5316                    NodeFailureCode::ContextPackMaterializationFailed,
5317                    "session context pack byte revalidation failed",
5318                ))?;
5319        }
5320        let transport = match mode {
5321            SessionMode::Pty => TransportKind::Pty,
5322            SessionMode::Inline => TransportKind::Pipe,
5323            SessionMode::Acp => TransportKind::Acp,
5324        };
5325        let overlay = if let Some(extra_args) = bundle_arguments {
5326            Some(PreparedNativeLaunchOverlay::Instance(
5327                NativeInstanceLaunchOverlay::new(
5328                    provider.clone(),
5329                    transport,
5330                    if has_environment_overlay {
5331                        environment
5332                    } else {
5333                        Vec::new()
5334                    },
5335                    extra_args,
5336                )
5337                .map_err(|_| {
5338                    failure(
5339                        NodeFailureCode::BundleBindingMismatch,
5340                        "session bundle launch overlay is invalid",
5341                    )
5342                })?,
5343            ))
5344        } else if has_environment_overlay {
5345            let context_only = environment_profile.is_none()
5346                && bundle.is_none()
5347                && context.is_some();
5348            let overlay = if context_only {
5349                NativeLaunchEnvironmentOverlay::new_context_root(
5350                    provider.clone(),
5351                    transport,
5352                    environment,
5353                )
5354                .map_err(|_| {
5355                    failure(
5356                        NodeFailureCode::ContextPackMaterializationFailed,
5357                        "context-only launch overlay is invalid",
5358                    )
5359                })?
5360            } else {
5361                NativeLaunchEnvironmentOverlay::new(
5362                    provider.clone(),
5363                    transport,
5364                    environment,
5365                )
5366                .map_err(|_| {
5367                    failure(
5368                        NodeFailureCode::BackendOperationFailed,
5369                        "session environment overlay is invalid",
5370                    )
5371                })?
5372            };
5373            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5374        } else {
5375            None
5376        };
5377        Ok(Some((overlay, guard)))
5378    }
5379
5380    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5381        let Some(mut ownership) = self
5382            .materializations
5383            .lock()
5384            .unwrap_or_else(|poisoned| poisoned.into_inner())
5385            .get(id)
5386            .cloned()
5387        else {
5388            return Ok(());
5389        };
5390        if ownership.state() != MaterializationState::CleanupRequired {
5391            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5392                failure(
5393                    NodeFailureCode::BackendOperationFailed,
5394                    "session environment cleanup state is invalid",
5395                )
5396            })?;
5397        }
5398        {
5399            let _transaction = self
5400                .state_transaction
5401                .lock()
5402                .unwrap_or_else(|poisoned| poisoned.into_inner());
5403            let previous = self
5404                .materializations
5405                .lock()
5406                .unwrap_or_else(|poisoned| poisoned.into_inner())
5407                .insert(id.clone(), ownership.clone());
5408            if let Err(error) = self.persist_state_locked() {
5409                let mut materializations = self
5410                    .materializations
5411                    .lock()
5412                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5413                if let Some(previous) = previous {
5414                    materializations.insert(id.clone(), previous);
5415                }
5416                return Err(persistence_failure(error));
5417            }
5418        }
5419        let cleanup = self
5420            .session_environment_materializer
5421            .as_ref()
5422            .ok_or(())
5423            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5424        if cleanup.is_err() {
5425            let _ = ownership.mark_recovery_required(unix_time_ms());
5426            self.materializations
5427                .lock()
5428                .unwrap_or_else(|poisoned| poisoned.into_inner())
5429                .insert(id.clone(), ownership);
5430            if self.persist_state().is_err() {
5431                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5432            }
5433            return Err(failure(
5434                NodeFailureCode::BackendOperationFailed,
5435                "session environment cleanup requires recovery",
5436            ));
5437        }
5438        let _transaction = self
5439            .state_transaction
5440            .lock()
5441            .unwrap_or_else(|poisoned| poisoned.into_inner());
5442        self.materializations
5443            .lock()
5444            .unwrap_or_else(|poisoned| poisoned.into_inner())
5445            .remove(id);
5446        if let Err(error) = self.persist_state_locked() {
5447            self.materializations
5448                .lock()
5449                .unwrap_or_else(|poisoned| poisoned.into_inner())
5450                .insert(id.clone(), ownership);
5451            return Err(persistence_failure(error));
5452        }
5453        Ok(())
5454    }
5455
5456    fn resolve_record_materialization(
5457        &self,
5458        record_id: &SessionRecordId,
5459        provider: &AgentId,
5460        mode: SessionMode,
5461        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5462        bundle: Option<&ResolvedBundleReceipt>,
5463        context: Option<&ResolvedContextPackReceipt>,
5464    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5465        let ownership = {
5466            let materializations = self
5467                .materializations
5468                .lock()
5469                .unwrap_or_else(|poisoned| poisoned.into_inner());
5470            let mut matches = materializations.values().filter(|record| {
5471                record.owner()
5472                    == &MaterializationOwner::Record {
5473                        record_id: record_id.clone(),
5474                    }
5475            });
5476            let ownership = matches.next().cloned();
5477            if matches.next().is_some() {
5478                return Err(failure(
5479                    NodeFailureCode::BackendOperationFailed,
5480                    "managed session has conflicting environment ownership",
5481                ));
5482            }
5483            ownership
5484        };
5485        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5486            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5487                failure(
5488                    NodeFailureCode::ContextPackMaterializationFailed,
5489                    "session context materializer is unavailable",
5490                )
5491            })?;
5492            let pack = materializer
5493                .revalidate_context(ownership, receipt)
5494                .map_err(|_| {
5495                    self.mark_materialization_recovery_required(ownership.id());
5496                    failure(
5497                        NodeFailureCode::ContextPackMaterializationFailed,
5498                        "managed session context pack revalidation failed",
5499                    )
5500                })?;
5501            self.context_catalog
5502                .write()
5503                .unwrap_or_else(|poisoned| poisoned.into_inner())
5504                .insert(pack)
5505                .map_err(|_| failure(
5506                    NodeFailureCode::ContextPackMaterializationFailed,
5507                    "managed session context pack catalog restore failed",
5508                ))?;
5509        }
5510        let materialization = self.materialization_profile(
5511            provider,
5512            mode,
5513            environment_profile,
5514            bundle,
5515            context,
5516        )?;
5517        match (ownership, materialization) {
5518            (None, None) => Ok(None),
5519            (None, Some(_)) | (Some(_), None) => Err(failure(
5520                NodeFailureCode::BackendOperationFailed,
5521                "managed session materialization is unavailable",
5522            )),
5523            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5524                if ownership.environment_profile() != environment_profile
5525                    || ownership.bundle() != bundle
5526                    || ownership.context() != context
5527                    || ownership.state() != MaterializationState::Ready
5528                {
5529                    self.mark_materialization_recovery_required(ownership.id());
5530                    return Err(failure(
5531                        NodeFailureCode::BackendOperationFailed,
5532                        "managed session environment ownership requires recovery",
5533                    ));
5534                }
5535                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5536                    failure(
5537                        NodeFailureCode::BackendOperationFailed,
5538                        "session environment materializer is unavailable",
5539                    )
5540                })?;
5541                if materializer.revalidate(&ownership).is_err() {
5542                    self.mark_materialization_recovery_required(ownership.id());
5543                    return Err(failure(
5544                        NodeFailureCode::BackendOperationFailed,
5545                        "managed session environment revalidation failed",
5546                    ));
5547                }
5548                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5549                    let catalog = self.bundle_catalog.read()
5550                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5551                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5552                        return Err(failure(
5553                            NodeFailureCode::UnknownBundle,
5554                            "managed session bundle is unavailable",
5555                        ));
5556                    };
5557                    let layout = bundle_layout.ok_or_else(|| failure(
5558                        NodeFailureCode::BundleBindingMismatch,
5559                        "record bundle provider layout is unavailable",
5560                    ))?;
5561                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5562                        drop(catalog);
5563                        self.mark_materialization_recovery_required(ownership.id());
5564                        return Err(failure(
5565                            NodeFailureCode::BundleMaterializationFailed,
5566                            "managed session bundle revalidation failed",
5567                        ));
5568                    }
5569                    Some(bundle_launch_arguments(
5570                        layout,
5571                        ownership.bundle_root(),
5572                        ownership.provider_home(),
5573                    ).map_err(|_| failure(
5574                        NodeFailureCode::BundleBindingMismatch,
5575                        "managed session bundle launch binding is invalid",
5576                    ))?)
5577                } else {
5578                    None
5579                };
5580                if !has_environment_materialization {
5581                    let overlay = bundle_arguments.map(|extra_args| {
5582                        NativeInstanceLaunchOverlay::new(
5583                            provider.clone(),
5584                            TransportKind::Pty,
5585                            Vec::new(),
5586                            extra_args,
5587                        )
5588                        .map(PreparedNativeLaunchOverlay::Instance)
5589                        .map_err(|_| failure(
5590                            NodeFailureCode::BundleBindingMismatch,
5591                            "managed session bundle launch overlay is invalid",
5592                        ))
5593                    }).transpose()?;
5594                    return Ok(Some((ownership.id().clone(), overlay)));
5595                }
5596                let environment = match materializer.resolve_environment(&ownership, &profile) {
5597                    Ok(environment) => environment,
5598                    Err(
5599                        SessionEnvironmentMaterializeError::SecretUnavailable
5600                        | SessionEnvironmentMaterializeError::SecretDenied
5601                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5602                    ) => {
5603                        return Err(failure(
5604                            NodeFailureCode::BackendOperationFailed,
5605                            "managed session environment resolution failed",
5606                        ));
5607                    }
5608                    Err(
5609                        SessionEnvironmentMaterializeError::InvalidRoot
5610                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5611                        | SessionEnvironmentMaterializeError::Filesystem(_)
5612                        | SessionEnvironmentMaterializeError::Record(_),
5613                    ) => {
5614                        self.mark_materialization_recovery_required(ownership.id());
5615                        return Err(failure(
5616                            NodeFailureCode::BackendOperationFailed,
5617                            "managed session environment resolution failed",
5618                        ));
5619                    }
5620                };
5621                let transport = match mode {
5622                    SessionMode::Pty => TransportKind::Pty,
5623                    SessionMode::Inline => TransportKind::Pipe,
5624                    SessionMode::Acp => TransportKind::Acp,
5625                };
5626                let overlay = match bundle_arguments {
5627                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5628                        NativeInstanceLaunchOverlay::new(
5629                            provider.clone(),
5630                            transport,
5631                            environment,
5632                            extra_args,
5633                        )
5634                        .map_err(|_| failure(
5635                            NodeFailureCode::BundleBindingMismatch,
5636                            "managed session bundle launch overlay is invalid",
5637                        ))?,
5638                    ),
5639                    None => PreparedNativeLaunchOverlay::Environment(
5640                        NativeLaunchEnvironmentOverlay::new(
5641                            provider.clone(),
5642                            transport,
5643                            environment,
5644                        )
5645                        .map_err(|_| {
5646                            failure(
5647                                NodeFailureCode::BackendOperationFailed,
5648                                "managed session environment overlay is invalid",
5649                            )
5650                        })?,
5651                    ),
5652                };
5653                Ok(Some((ownership.id().clone(), Some(overlay))))
5654            }
5655        }
5656    }
5657
5658    fn destroy_record_materialization(
5659        &self,
5660        record_id: &SessionRecordId,
5661    ) -> Result<bool, NodeFailure> {
5662        let ids = self
5663            .materializations
5664            .lock()
5665            .unwrap_or_else(|poisoned| poisoned.into_inner())
5666            .values()
5667            .filter_map(|record| {
5668                (record.owner()
5669                    == &MaterializationOwner::Record {
5670                        record_id: record_id.clone(),
5671                    })
5672                .then(|| record.id().clone())
5673            })
5674            .collect::<Vec<_>>();
5675        if ids.len() > 1 {
5676            return Err(failure(
5677                NodeFailureCode::BackendOperationFailed,
5678                "managed session has conflicting environment ownership",
5679            ));
5680        }
5681        let Some(id) = ids.first().cloned() else {
5682            return Ok(false);
5683        };
5684        let (terminal_record, mut cleanup_ownership) = {
5685            let _transaction = self
5686                .state_transaction
5687                .lock()
5688                .unwrap_or_else(|poisoned| poisoned.into_inner());
5689            let original_record = self.record(record_id)?;
5690            let mut terminal_record = original_record.clone();
5691            terminal_record.provider_session = None;
5692            terminal_record.active_session = None;
5693            terminal_record.state = ManagedSessionState::Unavailable;
5694            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5695            terminal_record.updated_at_unix_ms = unix_time_ms();
5696            let original_ownership = self
5697                .materializations
5698                .lock()
5699                .unwrap_or_else(|poisoned| poisoned.into_inner())
5700                .get(&id)
5701                .cloned()
5702                .ok_or_else(|| {
5703                    failure(
5704                        NodeFailureCode::BackendOperationFailed,
5705                        "managed session environment ownership disappeared during cleanup",
5706                    )
5707                })?;
5708            let mut cleanup_ownership = original_ownership.clone();
5709            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5710                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5711                    failure(
5712                        NodeFailureCode::BackendOperationFailed,
5713                        "managed session environment cleanup state is invalid",
5714                    )
5715                })?;
5716            }
5717            self.session_records
5718                .lock()
5719                .unwrap_or_else(|poisoned| poisoned.into_inner())
5720                .records
5721                .insert(record_id.clone(), terminal_record.clone());
5722            self.materializations
5723                .lock()
5724                .unwrap_or_else(|poisoned| poisoned.into_inner())
5725                .insert(id.clone(), cleanup_ownership.clone());
5726            if let Err(error) = self.persist_state_locked() {
5727                self.session_records
5728                    .lock()
5729                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5730                    .records
5731                    .insert(record_id.clone(), original_record);
5732                self.materializations
5733                    .lock()
5734                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5735                    .insert(id.clone(), original_ownership);
5736                return Err(persistence_failure(error));
5737            }
5738            (terminal_record, cleanup_ownership)
5739        };
5740        let cleanup = self
5741            .session_environment_materializer
5742            .as_ref()
5743            .ok_or(())
5744            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5745        if cleanup.is_err() {
5746            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5747            self.materializations
5748                .lock()
5749                .unwrap_or_else(|poisoned| poisoned.into_inner())
5750                .insert(id.clone(), cleanup_ownership);
5751            if self.persist_state().is_err() {
5752                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5753            }
5754            return Err(failure(
5755                NodeFailureCode::BackendOperationFailed,
5756                "managed session environment cleanup requires recovery",
5757            ));
5758        }
5759        let _transaction = self
5760            .state_transaction
5761            .lock()
5762            .unwrap_or_else(|poisoned| poisoned.into_inner());
5763        self.remove_record_memory(record_id);
5764        self.materializations
5765            .lock()
5766            .unwrap_or_else(|poisoned| poisoned.into_inner())
5767            .remove(&id);
5768        if let Err(error) = self.persist_state_locked() {
5769            self.session_records
5770                .lock()
5771                .unwrap_or_else(|poisoned| poisoned.into_inner())
5772                .records
5773                .insert(record_id.clone(), terminal_record);
5774            self.materializations
5775                .lock()
5776                .unwrap_or_else(|poisoned| poisoned.into_inner())
5777                .insert(id, cleanup_ownership);
5778            return Err(persistence_failure(error));
5779        }
5780        Ok(true)
5781    }
5782
5783    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5784        let changed = {
5785            let mut materializations = self
5786                .materializations
5787                .lock()
5788                .unwrap_or_else(|poisoned| poisoned.into_inner());
5789            let Some(ownership) = materializations.get_mut(id) else {
5790                return;
5791            };
5792            ownership.mark_recovery_required(unix_time_ms()).is_ok()
5793        };
5794        if changed && self.persist_state().is_err() {
5795            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5796        }
5797    }
5798
5799    fn reconcile_materializations(&self) {
5800        let records = self
5801            .materializations
5802            .lock()
5803            .unwrap_or_else(|poisoned| poisoned.into_inner())
5804            .values()
5805            .cloned()
5806            .collect::<Vec<_>>();
5807        for mut ownership in records {
5808            let result = match (ownership.state(), ownership.owner()) {
5809                (MaterializationState::Preparing, _)
5810                | (MaterializationState::CleanupRequired, _) => {
5811                    self.cleanup_materialization(ownership.id())
5812                }
5813                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5814                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5815                        self.materializations
5816                            .lock()
5817                            .unwrap_or_else(|poisoned| poisoned.into_inner())
5818                            .insert(ownership.id().clone(), ownership.clone());
5819                        self.persist_state().map_err(persistence_failure)
5820                    } else {
5821                        Err(failure(
5822                            NodeFailureCode::BackendOperationFailed,
5823                            "session environment reconciliation failed",
5824                        ))
5825                    }
5826                }
5827                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5828                    .session_environment_materializer
5829                    .as_ref()
5830                    .ok_or_else(|| {
5831                        failure(
5832                            NodeFailureCode::BackendOperationFailed,
5833                            "session environment materializer is unavailable",
5834                        )
5835                    })
5836                    .and_then(|materializer| {
5837                        materializer.revalidate(&ownership).map_err(|_| failure(
5838                            NodeFailureCode::BackendOperationFailed,
5839                            "session materialization revalidation failed",
5840                        ))?;
5841                        let record = self.record(record_id)?;
5842                        if record.environment_profile.as_ref() != ownership.environment_profile()
5843                            || record.bundle.as_ref() != ownership.bundle()
5844                            || record.context.as_ref() != ownership.context()
5845                        {
5846                            return Err(failure(
5847                                NodeFailureCode::BackendOperationFailed,
5848                                "session materialization ownership changed after restart",
5849                            ));
5850                        }
5851                        if let Some(receipt) = ownership.bundle() {
5852                            let catalog = self.bundle_catalog.read()
5853                                .unwrap_or_else(|poisoned| poisoned.into_inner());
5854                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5855                                NodeFailureCode::UnknownBundle,
5856                                "session bundle is unavailable after restart",
5857                            ))?;
5858                            if bundle.receipt() != *receipt {
5859                                return Err(failure(
5860                                    NodeFailureCode::BundleBindingMismatch,
5861                                    "session bundle changed after restart",
5862                                ));
5863                            }
5864                            let layout = self.bundle_layout(
5865                                &record.provider,
5866                                record.mode,
5867                                record.environment_profile.as_ref(),
5868                                bundle,
5869                            )?;
5870                            materializer
5871                                .revalidate_bundle(&ownership, bundle, layout)
5872                                .map_err(|_| failure(
5873                                    NodeFailureCode::BundleMaterializationFailed,
5874                                    "session bundle revalidation failed",
5875                                ))?;
5876                        }
5877                        if let Some(receipt) = ownership.context() {
5878                            let pack = materializer
5879                                .revalidate_context(&ownership, receipt)
5880                                .map_err(|_| {
5881                                    failure(
5882                                        NodeFailureCode::ContextPackMaterializationFailed,
5883                                        "session context revalidation failed",
5884                                    )
5885                                })?;
5886                            self.context_catalog
5887                                .write()
5888                                .unwrap_or_else(|poisoned| poisoned.into_inner())
5889                                .insert(pack)
5890                                .map_err(|_| {
5891                                    failure(
5892                                        NodeFailureCode::ContextPackMaterializationFailed,
5893                                        "session context catalog restore failed",
5894                                    )
5895                                })?;
5896                        }
5897                        Ok(())
5898                    }),
5899                (MaterializationState::RecoveryRequired, _) => Ok(()),
5900            };
5901            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5902                let _ = ownership.mark_recovery_required(unix_time_ms());
5903                self.materializations
5904                    .lock()
5905                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5906                    .insert(ownership.id().clone(), ownership);
5907                if self.persist_state().is_err() {
5908                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5909                }
5910            }
5911        }
5912        let materialized_profiles = self
5913            .environment_materialization_profiles
5914            .read()
5915            .unwrap_or_else(|poisoned| poisoned.into_inner())
5916            .keys()
5917            .cloned()
5918            .collect::<Vec<_>>();
5919        let ownership = self
5920            .materializations
5921            .lock()
5922            .unwrap_or_else(|poisoned| poisoned.into_inner())
5923            .values()
5924            .cloned()
5925            .collect::<Vec<_>>();
5926        let changed = {
5927            let mut session_records = self
5928                .session_records
5929                .lock()
5930                .unwrap_or_else(|poisoned| poisoned.into_inner());
5931            let mut changed = false;
5932            for record in session_records.records.values_mut() {
5933                let environment_profile = record.environment_profile.as_ref();
5934                if environment_profile.is_none()
5935                    && record.bundle.is_none()
5936                    && record.context.is_none()
5937                {
5938                    continue;
5939                }
5940                if record.bundle.is_none()
5941                    && environment_profile.is_some_and(|profile| {
5942                        !materialized_profiles.contains(&profile.profile_id)
5943                    })
5944                {
5945                    continue;
5946                }
5947                let has_exact_ownership = ownership.iter().any(|candidate| {
5948                    candidate.state() == MaterializationState::Ready
5949                        && candidate.environment_profile() == environment_profile
5950                        && candidate.bundle() == record.bundle.as_ref()
5951                        && candidate.context() == record.context.as_ref()
5952                        && candidate.owner()
5953                            == &MaterializationOwner::Record {
5954                                record_id: record.record_id.clone(),
5955                            }
5956                });
5957                if !has_exact_ownership {
5958                    record.provider_session = None;
5959                    record.active_session = None;
5960                    record.state = ManagedSessionState::Unavailable;
5961                    record.last_error = Some(if record.bundle.is_some() {
5962                        "bundle-unavailable".to_owned()
5963                    } else if record.context.is_some() {
5964                        "context-unavailable".to_owned()
5965                    } else {
5966                        "environment-profile-unavailable".to_owned()
5967                    });
5968                    record.updated_at_unix_ms = unix_time_ms();
5969                    changed = true;
5970                }
5971            }
5972            changed
5973        };
5974        if changed && self.persist_state().is_err() {
5975            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5976        }
5977    }
5978
5979    fn replay_spawn_spec(
5980        &self,
5981        spec: &SpawnSpec,
5982        now: Instant,
5983    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5984        let mut cache = self
5985            .spawn_idempotency
5986            .lock()
5987            .unwrap_or_else(|poisoned| poisoned.into_inner());
5988        cache.entries.retain(|_, entry| entry.expires_at > now);
5989        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5990            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5991                return Err(failure(
5992                    NodeFailureCode::SpawnIdempotencyConflict,
5993                    "spawn idempotency key was reused across spawn kinds",
5994                ));
5995            };
5996            if existing != spec {
5997                return Err(failure(
5998                    NodeFailureCode::SpawnIdempotencyConflict,
5999                    "spawn idempotency key was reused with a different specification",
6000                ));
6001            }
6002            return Ok(Some(result.clone()));
6003        }
6004        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6005            return Err(failure(
6006                NodeFailureCode::SpawnIdempotencyCapacity,
6007                "spawn idempotency cache reached its bounded capacity",
6008            ));
6009        }
6010        Ok(None)
6011    }
6012
6013    fn remember_spawn_spec(
6014        &self,
6015        spec: SpawnSpec,
6016        result: Result<ResolvedSpawnReceipt, NodeFailure>,
6017        accepted_at: Instant,
6018    ) {
6019        self
6020            .spawn_idempotency
6021            .lock()
6022            .unwrap_or_else(|poisoned| poisoned.into_inner())
6023            .entries
6024            .insert(
6025                spec.idempotency_key.clone(),
6026                SpawnIdempotencyEntry {
6027                    value: SpawnIdempotencyValue::Standard { spec, result },
6028                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6029                },
6030            );
6031    }
6032
6033    fn claim_harness_mcp_spawn(
6034        &self,
6035        _reservation_id: &HarnessMcpReservationId,
6036        _activation_digest: &HarnessMcpActivationDigest,
6037        spec: &SpawnSpec,
6038        now: Instant,
6039    ) -> Result<(), NodeFailure> {
6040        let mut cache = self.spawn_idempotency.lock()
6041            .unwrap_or_else(|poisoned| poisoned.into_inner());
6042        cache.entries.retain(|_, entry| entry.expires_at > now);
6043        if cache.entries.contains_key(&spec.idempotency_key) {
6044            return Err(failure(
6045                NodeFailureCode::SpawnIdempotencyConflict,
6046                "spawn idempotency key was reused across spawn kinds",
6047            ));
6048        }
6049        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6050            return Err(failure(
6051                NodeFailureCode::SpawnIdempotencyCapacity,
6052                "spawn idempotency cache reached its bounded capacity",
6053            ));
6054        }
6055        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
6056            value: SpawnIdempotencyValue::HarnessMcp,
6057            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6058        });
6059        Ok(())
6060    }
6061
6062    fn replay_managed_spawn(
6063        &self,
6064        request: &ManagedWorktreeSpawnRequest,
6065        now: Instant,
6066    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6067        let mut cache = self.spawn_idempotency.lock()
6068            .unwrap_or_else(|poisoned| poisoned.into_inner());
6069        cache.entries.retain(|_, entry| entry.expires_at > now);
6070        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6071            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6072                return Err(failure(
6073                    NodeFailureCode::SpawnIdempotencyConflict,
6074                    "spawn idempotency key was reused across spawn kinds",
6075                ));
6076            };
6077            if existing != request {
6078                return Err(failure(
6079                    NodeFailureCode::SpawnIdempotencyConflict,
6080                    "spawn idempotency key was reused with a different managed specification",
6081                ));
6082            }
6083            return Ok(Some(result.clone()));
6084        }
6085        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6086            return Err(failure(
6087                NodeFailureCode::SpawnIdempotencyCapacity,
6088                "spawn idempotency cache reached its bounded capacity",
6089            ));
6090        }
6091        Ok(None)
6092    }
6093
6094    fn remember_managed_spawn(
6095        &self,
6096        request: ManagedWorktreeSpawnRequest,
6097        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6098        accepted_at: Instant,
6099    ) {
6100        self.spawn_idempotency.lock()
6101            .unwrap_or_else(|poisoned| poisoned.into_inner())
6102            .entries
6103            .insert(
6104                request.spawn_spec.idempotency_key.clone(),
6105                SpawnIdempotencyEntry {
6106                    value: SpawnIdempotencyValue::Managed { request, result },
6107                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6108                },
6109            );
6110    }
6111
6112    fn remember_managed_spawn_attempt(
6113        &self,
6114        durable_v2_key: Option<&SpawnIdempotencyKey>,
6115        request: ManagedWorktreeSpawnRequest,
6116        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6117        accepted_at: Instant,
6118    ) {
6119        if durable_v2_key.is_none() {
6120            self.remember_managed_spawn(request, result, accepted_at);
6121        }
6122    }
6123
6124    fn acquire_durable_managed_spawn_v2(
6125        &self,
6126        request: &ManagedWorktreeSpawnRequestV2,
6127        request_digest: String,
6128    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6129        if self.state_path.is_none() {
6130            return Err(failure(
6131                NodeFailureCode::BackendOperationFailed,
6132                "managed worktree V2 requires durable node state",
6133            ));
6134        }
6135        let _transaction = self.state_transaction
6136            .lock()
6137            .unwrap_or_else(|poisoned| poisoned.into_inner());
6138        let key = request.spawn_spec.idempotency_key.clone();
6139        let mut previous = None;
6140        {
6141            let mut replays = self.managed_spawn_replays
6142                .lock()
6143                .unwrap_or_else(|poisoned| poisoned.into_inner());
6144            if let Some(record) = replays.get_mut(&key) {
6145                if record.request_digest != request_digest {
6146                    return Err(failure(
6147                        NodeFailureCode::SpawnIdempotencyConflict,
6148                        "managed worktree V2 idempotency key was reused with a different request",
6149                    ));
6150                }
6151                match &record.state {
6152                    ManagedWorktreeSpawnReplayStateV10::Pending {
6153                        owner_incarnation_id,
6154                        lease_id: None,
6155                    } if owner_incarnation_id != &self.incarnation_id => {
6156                        previous = Some(record.clone());
6157                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6158                            owner_incarnation_id: self.incarnation_id,
6159                            lease_id: None,
6160                        };
6161                        record.updated_at_unix_ms = unix_time_ms();
6162                    }
6163                    ManagedWorktreeSpawnReplayStateV10::Pending {
6164                        lease_id: None,
6165                        ..
6166                    } => {
6167                        return Err(failure(
6168                            NodeFailureCode::BackendBusy,
6169                            "managed worktree V2 reservation is already active",
6170                        ));
6171                    }
6172                    ManagedWorktreeSpawnReplayStateV10::Pending {
6173                        lease_id: Some(_),
6174                        ..
6175                    } => {
6176                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6177                    }
6178                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6179                        return Ok(DurableManagedSpawnReplayDecision::Committed(
6180                            receipt.clone(),
6181                        ));
6182                    }
6183                }
6184            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6185                return Err(failure(
6186                    NodeFailureCode::SpawnIdempotencyCapacity,
6187                    "durable managed worktree replay ledger reached its bounded capacity",
6188                ));
6189            } else {
6190                let now = unix_time_ms();
6191                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6192                    idempotency_key: key.clone(),
6193                    request_digest,
6194                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6195                    profile_id: request.worktree_profile_id.clone(),
6196                    expected_profile_revision: request.expected_profile_revision.clone(),
6197                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
6198                        owner_incarnation_id: self.incarnation_id,
6199                        lease_id: None,
6200                    },
6201                    created_at_unix_ms: now,
6202                    updated_at_unix_ms: now,
6203                });
6204            }
6205        }
6206        if let Err(error) = self.persist_state_locked() {
6207            let mut replays = self.managed_spawn_replays
6208                .lock()
6209                .unwrap_or_else(|poisoned| poisoned.into_inner());
6210            match previous {
6211                Some(previous) => {
6212                    replays.insert(key, previous);
6213                }
6214                None => {
6215                    replays.remove(&key);
6216                }
6217            }
6218            return Err(persistence_failure(error));
6219        }
6220        Ok(DurableManagedSpawnReplayDecision::Reserved)
6221    }
6222
6223    fn link_durable_managed_spawn_v2_lease_locked(
6224        &self,
6225        key: &SpawnIdempotencyKey,
6226        lease_id: &ManagedWorktreeLeaseId,
6227    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6228        let mut replays = self.managed_spawn_replays
6229            .lock()
6230            .unwrap_or_else(|poisoned| poisoned.into_inner());
6231        let record = replays.get_mut(key).ok_or_else(|| {
6232            failure(
6233                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6234                "managed worktree V2 reservation disappeared before allocation",
6235            )
6236        })?;
6237        let previous = record.clone();
6238        match &mut record.state {
6239            ManagedWorktreeSpawnReplayStateV10::Pending {
6240                lease_id: current,
6241                ..
6242            }
6243                if current.is_none() =>
6244            {
6245                *current = Some(lease_id.clone());
6246                record.updated_at_unix_ms = unix_time_ms();
6247                Ok(previous)
6248            }
6249            _ => Err(failure(
6250                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6251                "managed worktree V2 reservation is not allocation-ready",
6252            )),
6253        }
6254    }
6255
6256    fn restore_durable_managed_spawn_v2_locked(
6257        &self,
6258        key: &SpawnIdempotencyKey,
6259        previous: ManagedWorktreeSpawnReplayRecordV10,
6260    ) {
6261        self.managed_spawn_replays
6262            .lock()
6263            .unwrap_or_else(|poisoned| poisoned.into_inner())
6264            .insert(key.clone(), previous);
6265    }
6266
6267    fn commit_durable_managed_spawn_v2(
6268        &self,
6269        request: &ManagedWorktreeSpawnRequestV2,
6270        receipt: ManagedWorktreeSpawnReceipt,
6271    ) -> Result<(), NodeFailure> {
6272        let _transaction = self.state_transaction
6273            .lock()
6274            .unwrap_or_else(|poisoned| poisoned.into_inner());
6275        let key = &request.spawn_spec.idempotency_key;
6276        let previous = {
6277            let mut replays = self.managed_spawn_replays
6278                .lock()
6279                .unwrap_or_else(|poisoned| poisoned.into_inner());
6280            let record = replays.get_mut(key).ok_or_else(|| {
6281                failure(
6282                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6283                    "managed worktree V2 reservation disappeared before commit",
6284                )
6285            })?;
6286            let previous = record.clone();
6287            match &record.state {
6288                ManagedWorktreeSpawnReplayStateV10::Pending {
6289                    lease_id: Some(lease_id),
6290                    ..
6291                } if lease_id == &receipt.lease.lease_id => {}
6292                _ => {
6293                    return Err(failure(
6294                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
6295                        "managed worktree V2 reservation is not linked to the spawned lease",
6296                    ));
6297                }
6298            }
6299            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6300            record.updated_at_unix_ms = unix_time_ms();
6301            previous
6302        };
6303        if let Err(error) = self.persist_state_locked() {
6304            self.restore_durable_managed_spawn_v2_locked(key, previous);
6305            return Err(persistence_failure(error));
6306        }
6307        Ok(())
6308    }
6309
6310    fn remove_unallocated_durable_managed_spawn_v2(
6311        &self,
6312        request: &ManagedWorktreeSpawnRequestV2,
6313    ) -> Result<(), NodeFailure> {
6314        let _transaction = self.state_transaction
6315            .lock()
6316            .unwrap_or_else(|poisoned| poisoned.into_inner());
6317        let key = &request.spawn_spec.idempotency_key;
6318        let removed = {
6319            let mut replays = self.managed_spawn_replays
6320                .lock()
6321                .unwrap_or_else(|poisoned| poisoned.into_inner());
6322            match replays.get(key).map(|record| &record.state) {
6323                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6324                    lease_id: None,
6325                    ..
6326                }) => {
6327                    replays.remove(key)
6328                }
6329                _ => None,
6330            }
6331        };
6332        let Some(removed) = removed else {
6333            return Ok(());
6334        };
6335        if let Err(error) = self.persist_state_locked() {
6336            self.managed_spawn_replays
6337                .lock()
6338                .unwrap_or_else(|poisoned| poisoned.into_inner())
6339                .insert(key.clone(), removed);
6340            return Err(persistence_failure(error));
6341        }
6342        Ok(())
6343    }
6344
6345    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6346        if spec.target.node_id != self.node_id {
6347            return Err(failure(
6348                NodeFailureCode::SpawnTargetMismatch,
6349                "spawn target does not match this node",
6350            ));
6351        }
6352        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6353            failure(
6354                NodeFailureCode::UnknownSpawnProfile,
6355                "spawn profile is unavailable on this node",
6356            )
6357        })?;
6358        let resolved = spec.resolve(defaults).map_err(|error| match error {
6359            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6360                NodeFailureCode::SpawnProfileRevisionMismatch,
6361                "spawn profile revision does not match the loaded profile",
6362            ),
6363            _ => failure(
6364                NodeFailureCode::InvalidRequest,
6365                "spawn specification could not be resolved",
6366            ),
6367        })?;
6368        if resolved.target.worktree_id.is_some() {
6369            self.require_worktree_service(&resolved.target.workspace_id)?;
6370        }
6371        let environment_profile = self.resolve_environment_profile(&resolved)?;
6372        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6373        self.resolve_context(&resolved)?;
6374        Ok(resolved)
6375    }
6376
6377    async fn resolve_spawn_workspace(
6378        &self,
6379        resolved: &ResolvedSpawnSpec,
6380        deadline: Instant,
6381    ) -> Result<WorkspaceId, NodeFailure> {
6382        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6383            return Ok(resolved.target.workspace_id.clone());
6384        };
6385        if self.managed_worktrees.lock()
6386            .unwrap_or_else(|poisoned| poisoned.into_inner())
6387            .lease_for_workspace(worktree_id)
6388            .is_some()
6389        {
6390            return Err(failure(
6391                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6392                "managed worktree targets may only be spawned through their lease request",
6393            ));
6394        }
6395        self.require_worktree_service(&resolved.target.workspace_id)?;
6396        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6397        let selected_root = self.workspace_root(worktree_id)?;
6398        let remaining = spawn_deadline_remaining(deadline)?;
6399        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6400            .await
6401            .map_err(|_| {
6402                failure(
6403                    NodeFailureCode::SpawnDeadlineExceeded,
6404                    "Git worktree selection exceeded the spawn deadline",
6405                )
6406            })?
6407            .map_err(git_worktree_failure)?;
6408        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6409        Ok(worktree_id.clone())
6410    }
6411
6412    async fn spawn_from_spec(
6413        &self,
6414        spec: SpawnSpec,
6415    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6416        self.spawn_from_spec_with_proxy(spec, None).await
6417    }
6418
6419    async fn spawn_from_spec_with_proxy(
6420        &self,
6421        spec: SpawnSpec,
6422        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6423    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6424        let accepted_at = Instant::now();
6425        if harness_mcp.is_none() {
6426            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6427                return replayed;
6428            }
6429        }
6430        let resolved = self.resolve_spawn_spec(&spec)?;
6431        let environment_profile = self.resolve_environment_profile(&resolved)?;
6432        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6433        let context = self.resolve_context(&resolved)?;
6434        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6435        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6436        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6437        let spawn_workspace_id = match spawn_workspace_id {
6438            Ok(workspace_id) => workspace_id,
6439            Err(error) => {
6440                let result = Err(error);
6441                if harness_mcp.is_none() {
6442                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6443                }
6444                return result;
6445            }
6446        };
6447        if self.managed_worktrees.lock()
6448            .unwrap_or_else(|poisoned| poisoned.into_inner())
6449            .lease_for_workspace(&spawn_workspace_id)
6450            .is_some()
6451        {
6452            let result = Err(failure(
6453                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6454                "managed worktree targets may only be spawned through their lease request",
6455            ));
6456            if harness_mcp.is_none() {
6457                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6458            }
6459            return result;
6460        }
6461        let result = self
6462            .spawn_session_with_deadline(
6463                spawn_workspace_id,
6464                resolved.provider.clone(),
6465                resolved.mode,
6466                resolved.terminal_size,
6467                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6468                environment_profile.clone(),
6469                bundle.clone(),
6470                context.clone(),
6471                None,
6472                None,
6473                SpawnRecordPolicy::Always,
6474                Some(deadline),
6475                &required_capabilities,
6476                harness_mcp,
6477                resolved.approval_level,
6478                resolved.network_allowlist.clone(),
6479                resolved.browser_profile_id.clone(),
6480            )
6481            .await
6482            .map(|(session, _runtime_policy)| {
6483                resolved.receipt_with_materialization(
6484                    self.incarnation_id,
6485                    session,
6486                    environment_profile,
6487                    bundle,
6488                    context,
6489                )
6490            });
6491        if harness_mcp.is_none() {
6492            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6493        }
6494        result
6495    }
6496
6497    async fn spawn_from_spec_with_harness_mcp(
6498        &self,
6499        reservation_id: HarnessMcpReservationId,
6500        activation_digest: HarnessMcpActivationDigest,
6501        spec: SpawnSpec,
6502        deadline_unix_ms: u64,
6503    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6504        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6505            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6506        })?;
6507        if let Some(receipt) = registry
6508            .replay_spawn(&reservation_id, &activation_digest, &spec)
6509            .map_err(harness_mcp_failure)?
6510        {
6511            return Ok(receipt);
6512        }
6513        let resolved = self.resolve_spawn_spec(&spec)?;
6514        let prepared = registry
6515            .prepare_spawn(
6516                &reservation_id,
6517                &activation_digest,
6518                &spec,
6519                deadline_unix_ms,
6520                resolved.provider.clone(),
6521            )
6522            .map_err(harness_mcp_failure)?;
6523        self.claim_harness_mcp_spawn(
6524            &reservation_id,
6525            &activation_digest,
6526            &spec,
6527            Instant::now(),
6528        )?;
6529        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6530        let mut receipt = match spawn {
6531            Ok(receipt) => receipt,
6532            Err(error) => {
6533                let _ = registry.abort(&reservation_id, &activation_digest);
6534                return Err(error);
6535            }
6536        };
6537        let record_id = self
6538            .session_bindings
6539            .lock()
6540            .unwrap_or_else(|poisoned| poisoned.into_inner())
6541            .get(&receipt.session.session.instance_id)
6542            .and_then(|binding| binding.record_id.clone());
6543        let record_id = match record_id {
6544            Some(record_id) => record_id,
6545            None => {
6546                let rollback = self.remove_session(&receipt.session).await;
6547                let _ = registry.abort(&reservation_id, &activation_digest);
6548                if let Err(rollback_error) = rollback {
6549                    return Err(rollback_error);
6550                }
6551                return Err(failure(
6552                    NodeFailureCode::BindingMismatch,
6553                    "harness MCP spawn did not retain its exact managed record",
6554                ));
6555            }
6556        };
6557        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6558            reservation_id: reservation_id.clone(),
6559            activation_digest: activation_digest.clone(),
6560        });
6561        if let Err(error) = registry.mark_spawned(
6562            &prepared,
6563            receipt.session.clone(),
6564            record_id,
6565            receipt.clone(),
6566        ) {
6567            let rollback = self.remove_session(&receipt.session).await;
6568            let _ = registry.abort(&reservation_id, &activation_digest);
6569            if let Err(rollback_error) = rollback {
6570                return Err(rollback_error);
6571            }
6572            return Err(harness_mcp_failure(error));
6573        }
6574        Ok(receipt)
6575    }
6576
6577    async fn spawn_managed_worktree(
6578        &self,
6579        request: ManagedWorktreeSpawnRequest,
6580    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6581        self.spawn_managed_worktree_inner(request, None).await
6582    }
6583
6584    async fn spawn_managed_worktree_inner(
6585        &self,
6586        request: ManagedWorktreeSpawnRequest,
6587        durable_v2_key: Option<&SpawnIdempotencyKey>,
6588    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6589        let accepted_at = Instant::now();
6590        if durable_v2_key.is_none() {
6591            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6592                return replayed;
6593            }
6594        }
6595        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6596        if request.spawn_spec.target.worktree_id.is_some() {
6597            let result = Err(failure(
6598                NodeFailureCode::InvalidRequest,
6599                "managed worktree spawn must not select a caller-provided worktree",
6600            ));
6601            self.remember_managed_spawn_attempt(
6602                durable_v2_key,
6603                request,
6604                result.clone(),
6605                accepted_at,
6606            );
6607            return result;
6608        }
6609        let environment_profile = self.resolve_environment_profile(&resolved)?;
6610        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6611        let context = self.resolve_context(&resolved)?;
6612        let source_workspace_id = resolved.target.workspace_id.clone();
6613        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6614        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6615        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6616        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6617            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6618            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6619            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6620            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6621        };
6622        let runtime_policy = timeout(
6623            spawn_deadline_remaining(deadline)?,
6624            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6625        ).await.map_err(|_| failure(
6626            NodeFailureCode::SpawnDeadlineExceeded,
6627            "provider admission exceeded the managed spawn deadline",
6628        ))??;
6629        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6630            return Err(failure(
6631                NodeFailureCode::UnsupportedSpawnCapability,
6632                "provider runtime does not admit a required spawn capability",
6633            ));
6634        }
6635        self.ensure_binding_capacity()?;
6636        let source_root = self.workspace_root(&source_workspace_id)?;
6637        profile.validate_for_workspace(&source_root)
6638            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6639        let base_timeout_ms = u64::try_from(
6640            spawn_deadline_remaining(deadline)?.as_millis(),
6641        ).unwrap_or(u64::MAX).max(1);
6642        let base_commit = resolve_base_commit_with_timeout(
6643            &source_root,
6644            profile.base(),
6645            base_timeout_ms,
6646        ).await.map_err(|error| {
6647            if Instant::now() >= deadline || error.message.contains("timed out") {
6648                failure(
6649                    NodeFailureCode::SpawnDeadlineExceeded,
6650                    "managed worktree base resolution exceeded the spawn deadline",
6651                )
6652            } else {
6653                managed_git_worktree_failure(error)
6654            }
6655        })?;
6656
6657        let lease = {
6658            let _transaction = self.state_transaction.lock()
6659                .unwrap_or_else(|poisoned| poisoned.into_inner());
6660            let previous = self.managed_worktrees.lock()
6661                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6662            let lease = self.managed_worktrees.lock()
6663                .unwrap_or_else(|poisoned| poisoned.into_inner())
6664                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6665                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6666            let previous_replay = match durable_v2_key
6667                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6668                .transpose()
6669            {
6670                Ok(previous) => previous,
6671                Err(error) => {
6672                    *self.managed_worktrees.lock()
6673                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6674                    return Err(error);
6675                }
6676            };
6677            if let Err(error) = self.persist_state_locked() {
6678                *self.managed_worktrees.lock()
6679                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6680                if let (Some(key), Some(previous_replay)) =
6681                    (durable_v2_key, previous_replay)
6682                {
6683                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6684                }
6685                return Err(persistence_failure(error));
6686            }
6687            lease
6688        };
6689        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6690        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6691            self.managed_worktrees.lock()
6692                .unwrap_or_else(|poisoned| poisoned.into_inner())
6693                .remove_unmutated(&lease.lease_id);
6694            if self.persist_state().is_err() {
6695                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6696            }
6697            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6698            self.remember_managed_spawn_attempt(
6699                durable_v2_key,
6700                request,
6701                result.clone(),
6702                accepted_at,
6703            );
6704            return result;
6705        }
6706        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6707            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6708            Err(error) => {
6709                self.reconcile_failed_allocation(&lease, &source_root).await;
6710                let result = Err(error);
6711                self.remember_managed_spawn_attempt(
6712                    durable_v2_key,
6713                    request,
6714                    result.clone(),
6715                    accepted_at,
6716                );
6717                return result;
6718            }
6719        };
6720        let created = match create_git_worktree_with_timeout(
6721                &source_root,
6722                &lease.target_root,
6723                &lease.branch,
6724                Some(&base_commit),
6725                mutation_timeout_ms,
6726            ).await {
6727            Ok(created) => created,
6728            Err(error) => {
6729                self.reconcile_failed_allocation(&lease, &source_root).await;
6730                let result = Err(if Instant::now() >= deadline
6731                    || error.message.contains("timed out")
6732                    || error.message.contains("deadline")
6733                {
6734                    failure(
6735                        NodeFailureCode::SpawnDeadlineExceeded,
6736                        "managed worktree creation exceeded the spawn deadline",
6737                    )
6738                } else {
6739                    managed_git_worktree_failure(error)
6740                });
6741                self.remember_managed_spawn_attempt(
6742                    durable_v2_key,
6743                    request,
6744                    result.clone(),
6745                    accepted_at,
6746                );
6747                return result;
6748            }
6749        };
6750        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6751            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6752            let result = Err(failure(
6753                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6754                "created worktree identity did not match its durable lease",
6755            ));
6756            self.remember_managed_spawn_attempt(
6757                durable_v2_key,
6758                request,
6759                result.clone(),
6760                accepted_at,
6761            );
6762            return result;
6763        }
6764        {
6765            let mut registry = self.managed_worktrees.lock()
6766                .unwrap_or_else(|poisoned| poisoned.into_inner());
6767            let current = registry.get_mut(&lease.lease_id)
6768                .expect("allocated managed worktree lease remains present");
6769            current.expected_head = Some(created.head.clone());
6770            current.state = ManagedWorktreeLeaseState::Ready;
6771            current.updated_at_unix_ms = unix_time_ms();
6772        }
6773        if let Err(error) = self.persist_state() {
6774            self.mark_managed_recovery_required(
6775                &lease.lease_id,
6776                ManagedWorktreeCleanupFailure::Backend,
6777            );
6778            let result = Err(persistence_failure(error));
6779            self.remember_managed_spawn_attempt(
6780                durable_v2_key,
6781                request,
6782                result.clone(),
6783                accepted_at,
6784            );
6785            return result;
6786        }
6787        let ready = self.managed_worktrees.lock()
6788            .unwrap_or_else(|poisoned| poisoned.into_inner())
6789            .get(&lease.lease_id)
6790            .expect("ready managed worktree lease remains present")
6791            .snapshot();
6792        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6793        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6794            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6795            let result = Err(error);
6796            self.remember_managed_spawn_attempt(
6797                durable_v2_key,
6798                request,
6799                result.clone(),
6800                accepted_at,
6801            );
6802            return result;
6803        }
6804        let spawn = self.spawn_session_with_deadline(
6805            lease.workspace_id.clone(),
6806            resolved.provider.clone(),
6807            resolved.mode,
6808            resolved.terminal_size,
6809            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6810            environment_profile.clone(),
6811            bundle.clone(),
6812            context.clone(),
6813            Some(lease.lease_id.clone()),
6814            Some(runtime_policy),
6815            SpawnRecordPolicy::Always,
6816            Some(deadline),
6817            &required_capabilities,
6818            None,
6819            resolved.approval_level,
6820            resolved.network_allowlist.clone(),
6821            resolved.browser_profile_id.clone(),
6822        ).await;
6823        let (session, effective_runtime_policy) = match spawn {
6824            Ok(spawned) => spawned,
6825            Err(error) => {
6826                if self
6827                    .cleanup_managed_worktree(&lease.lease_id, true)
6828                    .await
6829                    .is_err()
6830                {
6831                    self.mark_managed_recovery_required(
6832                        &lease.lease_id,
6833                        ManagedWorktreeCleanupFailure::Backend,
6834                    );
6835                }
6836                let result = Err(error);
6837                self.remember_managed_spawn_attempt(
6838                    durable_v2_key,
6839                    request,
6840                    result.clone(),
6841                    accepted_at,
6842                );
6843                return result;
6844            }
6845        };
6846        let (record_id, raw_inventory_record_id) = {
6847            let mut bindings = self.session_bindings.lock()
6848                .unwrap_or_else(|poisoned| poisoned.into_inner());
6849            let binding = bindings.get_mut(&session.session.instance_id)
6850                .expect("successful spawn retains its session binding");
6851            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6852            managed_spawn_record_ownership(binding, effective_runtime_policy)
6853        };
6854        let bound = self.managed_worktrees.lock()
6855            .unwrap_or_else(|poisoned| poisoned.into_inner())
6856            .bind_session(
6857                &lease.lease_id,
6858                ManagedWorktreeSessionHolder {
6859                    incarnation_id: self.incarnation_id,
6860                    instance_id: session.session.instance_id,
6861                    generation: session.session.generation,
6862                },
6863                record_id,
6864                unix_time_ms(),
6865            )
6866            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6867        let snapshot = match bound {
6868            Ok(snapshot) => snapshot,
6869            Err(error) => {
6870                if raw_inventory_record_id
6871                    .as_ref()
6872                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
6873                {
6874                    self.mark_managed_recovery_required(
6875                        &lease.lease_id,
6876                        ManagedWorktreeCleanupFailure::Backend,
6877                    );
6878                }
6879                let _ = self.remove_session(&session).await;
6880                self.mark_managed_recovery_required(
6881                    &lease.lease_id,
6882                    ManagedWorktreeCleanupFailure::Backend,
6883                );
6884                let result = Err(error);
6885                self.remember_managed_spawn_attempt(
6886                    durable_v2_key,
6887                    request,
6888                    result.clone(),
6889                    accepted_at,
6890                );
6891                return result;
6892            }
6893        };
6894        if let Err(error) = self.persist_state() {
6895            if raw_inventory_record_id
6896                .as_ref()
6897                .is_some_and(|record_id| self.discard_record(record_id).is_err())
6898            {
6899                self.mark_managed_recovery_required(
6900                    &lease.lease_id,
6901                    ManagedWorktreeCleanupFailure::Backend,
6902                );
6903            }
6904            let _ = self.remove_session(&session).await;
6905            if self
6906                .cleanup_managed_worktree(&lease.lease_id, true)
6907                .await
6908                .is_err()
6909            {
6910                self.mark_managed_recovery_required(
6911                    &lease.lease_id,
6912                    ManagedWorktreeCleanupFailure::Backend,
6913                );
6914            }
6915            let result = Err(persistence_failure(error));
6916            self.remember_managed_spawn_attempt(
6917                durable_v2_key,
6918                request,
6919                result.clone(),
6920                accepted_at,
6921            );
6922            return result;
6923        }
6924        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6925        let receipt = managed_spawn_receipt(
6926            &resolved,
6927            self.incarnation_id,
6928            session,
6929            snapshot,
6930            environment_profile,
6931            bundle,
6932            context,
6933        );
6934        let result = Ok(receipt);
6935        self.remember_managed_spawn_attempt(
6936            durable_v2_key,
6937            request,
6938            result.clone(),
6939            accepted_at,
6940        );
6941        result
6942    }
6943
6944    async fn spawn_managed_worktree_v2(
6945        &self,
6946        request: ManagedWorktreeSpawnRequestV2,
6947    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6948        if request.spawn_spec.target.node_id != self.node_id {
6949            return Err(failure(
6950                NodeFailureCode::SpawnTargetMismatch,
6951                "spawn target does not match this node",
6952            ));
6953        }
6954        if request.spawn_spec.target.worktree_id.is_some() {
6955            return Err(failure(
6956                NodeFailureCode::InvalidRequest,
6957                "managed worktree spawn must not select a caller-provided worktree",
6958            ));
6959        }
6960        let request_digest = managed_spawn_request_digest_v2(&request)?;
6961        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6962        match replay {
6963            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6964            DurableManagedSpawnReplayDecision::PendingLinked => {
6965                return Err(failure(
6966                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6967                    "managed worktree V2 reservation has an incomplete durable allocation",
6968                ));
6969            }
6970            DurableManagedSpawnReplayDecision::Reserved => {}
6971        }
6972        let validated = (|| {
6973            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6974            let profile = self.managed_profile(
6975                &resolved.target.workspace_id,
6976                &request.worktree_profile_id,
6977            )?;
6978            if profile.revision() != &request.expected_profile_revision {
6979                return Err(failure(
6980                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6981                    "managed worktree profile revision does not match the loaded profile",
6982                ));
6983            }
6984            Ok(())
6985        })();
6986        match validated {
6987            Ok(()) => {}
6988            Err(error) => {
6989                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6990                return Err(error);
6991            }
6992        }
6993        let legacy = ManagedWorktreeSpawnRequest {
6994            spawn_spec: request.spawn_spec.clone(),
6995            worktree_profile_id: request.worktree_profile_id.clone(),
6996        };
6997        match self
6998            .spawn_managed_worktree_inner(
6999                legacy,
7000                Some(&request.spawn_spec.idempotency_key),
7001            )
7002            .await
7003        {
7004            Ok(receipt) => {
7005                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
7006                Ok(receipt)
7007            }
7008            Err(error) => {
7009                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7010                Err(error)
7011            }
7012        }
7013    }
7014
7015    fn managed_profile(
7016        &self,
7017        workspace_id: &WorkspaceId,
7018        profile_id: &WorktreeProfileId,
7019    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
7020        if self.worktree_service_modes.get(workspace_id).copied()
7021            != Some(WorktreeServiceMode::Managed)
7022        {
7023            return Err(failure(
7024                NodeFailureCode::UnsupportedSpawnCapability,
7025                "workspace does not enable managed worktrees",
7026            ));
7027        }
7028        self.managed_worktree_profiles.get(workspace_id)
7029            .and_then(|profiles| profiles.get(profile_id))
7030            .cloned()
7031            .ok_or_else(|| failure(
7032                NodeFailureCode::InvalidRequest,
7033                "managed worktree profile is unavailable for this workspace",
7034            ))
7035    }
7036
7037    async fn reconcile_failed_allocation(
7038        &self,
7039        lease: &ManagedWorktreeLeaseRecord,
7040        source_root: &str,
7041    ) {
7042        match list_git_worktrees(source_root).await {
7043            Ok(worktrees) => {
7044                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
7045                    None => {
7046                        match std::fs::symlink_metadata(&lease.target_root) {
7047                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
7048                                self.managed_worktrees.lock()
7049                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7050                                    .remove_unmutated(&lease.lease_id);
7051                                let _ = self.persist_state();
7052                            }
7053                            _ => self.mark_managed_recovery_required(
7054                                &lease.lease_id,
7055                                ManagedWorktreeCleanupFailure::OwnershipConflict,
7056                            ),
7057                        }
7058                    }
7059                    Some(item) if exact_owned_worktree(
7060                        lease,
7061                        &item.path,
7062                        item.branch.as_deref(),
7063                        &item.head,
7064                    ) => {
7065                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7066                    }
7067                    Some(_) => self.mark_managed_recovery_required(
7068                        &lease.lease_id,
7069                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7070                    ),
7071                }
7072            }
7073            Err(_) => self.mark_managed_recovery_required(
7074                &lease.lease_id,
7075                ManagedWorktreeCleanupFailure::Backend,
7076            ),
7077        }
7078    }
7079
7080    async fn reconcile_managed_worktrees(&self) {
7081        let leases = self.managed_worktrees.lock()
7082            .unwrap_or_else(|poisoned| poisoned.into_inner())
7083            .active_records()
7084            .cloned()
7085            .collect::<Vec<_>>();
7086        for lease in leases {
7087            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7088                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7089                _ => {
7090                    self.mark_managed_recovery_required(
7091                        &lease.lease_id,
7092                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7093                    );
7094                    continue;
7095                }
7096            };
7097            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7098                self.mark_managed_recovery_required(
7099                    &lease.lease_id,
7100                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7101                );
7102                continue;
7103            }
7104            let source_root = match self.workspace_root(&lease.source_workspace_id) {
7105                Ok(root) => root,
7106                Err(_) => {
7107                    self.mark_managed_recovery_required(
7108                        &lease.lease_id,
7109                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7110                    );
7111                    continue;
7112                }
7113            };
7114            let worktrees = match list_git_worktrees(&source_root).await {
7115                Ok(worktrees) => worktrees,
7116                Err(_) => {
7117                    self.mark_managed_recovery_required(
7118                        &lease.lease_id,
7119                        ManagedWorktreeCleanupFailure::Backend,
7120                    );
7121                    continue;
7122                }
7123            };
7124            let listed = worktrees.iter()
7125                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7126            let Some(listed) = listed else {
7127                match std::fs::symlink_metadata(&lease.target_root) {
7128                    Err(error) if error.kind() == io::ErrorKind::NotFound
7129                        && lease.state == ManagedWorktreeLeaseState::Allocating
7130                        && !lease.has_holders() => {
7131                            let removed = self.managed_worktrees.lock()
7132                                .unwrap_or_else(|poisoned| poisoned.into_inner())
7133                                .tombstone(&lease.lease_id, unix_time_ms());
7134                            if self.persist_state().is_ok() && removed.is_some() {
7135                                self.publish(NodeEvent::ManagedWorktreeRemoved {
7136                                    lease_id: lease.lease_id.clone(),
7137                                });
7138                            }
7139                        }
7140                    _ => self.mark_managed_recovery_required(
7141                        &lease.lease_id,
7142                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7143                    ),
7144                }
7145                continue;
7146            };
7147            if listed.is_main || listed.is_bare || !exact_owned_worktree(
7148                &lease,
7149                &listed.path,
7150                listed.branch.as_deref(),
7151                &listed.head,
7152            ) {
7153                self.mark_managed_recovery_required(
7154                    &lease.lease_id,
7155                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7156                );
7157                continue;
7158            }
7159            if !self.workspaces.read()
7160                .unwrap_or_else(|poisoned| poisoned.into_inner())
7161                .contains_key(&lease.workspace_id)
7162            {
7163                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7164                    self.mark_managed_recovery_required(
7165                        &lease.lease_id,
7166                        ManagedWorktreeCleanupFailure::Backend,
7167                    );
7168                    continue;
7169                }
7170            }
7171            let snapshot = {
7172                let mut registry = self.managed_worktrees.lock()
7173                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7174                let current = registry.get_mut(&lease.lease_id)
7175                    .expect("reconciled managed lease remains present");
7176                current.expected_head = Some(listed.head.clone());
7177                current.cleanup_failure = None;
7178                current.state = if current.has_holders() {
7179                    ManagedWorktreeLeaseState::InUse
7180                } else if current.retention == ManagedWorktreeRetention::Retain {
7181                    ManagedWorktreeLeaseState::Retained
7182                } else {
7183                    ManagedWorktreeLeaseState::Ready
7184                };
7185                current.updated_at_unix_ms = unix_time_ms();
7186                current.snapshot()
7187            };
7188            if self.persist_state().is_err() {
7189                self.mark_managed_recovery_required(
7190                    &lease.lease_id,
7191                    ManagedWorktreeCleanupFailure::Backend,
7192                );
7193                continue;
7194            }
7195            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7196            if snapshot.state == ManagedWorktreeLeaseState::Ready {
7197                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7198            }
7199        }
7200    }
7201
7202    /// Crash-recovery startup sweep: durably capture the pack for every
7203    /// `Live` managed record whose most recent session has not yet produced
7204    /// one (recovers a session whose `Exited{0}` event was observed but the
7205    /// Node crashed before the export finished). The reactive fast path for
7206    /// a single just-exited record does not go through this — see
7207    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
7208    /// it can capture the exact `SessionAddress` before `publish_control`
7209    /// clears it, rather than re-deriving it from (by then likely already
7210    /// `Dormant`) record state the way a rescan naturally would.
7211    ///
7212    /// Returns as soon as every eligible record's export has been *spawned*,
7213    /// not completed — each one runs on its own detached task (see
7214    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
7215    /// `NativeRuntime::tick()` has not even started its own loop yet at the
7216    /// point this is called from `NodeServer::run()`, so nothing could ever
7217    /// drain the history round trip if this awaited it inline here either).
7218    /// Best-effort throughout: never panics, no unwrap/expect; every failure
7219    /// is a silent skip for THIS record — an unusable source or a racing
7220    /// record is not surfaced to any caller.
7221    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7222        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7223            .session_records
7224            .lock()
7225            .unwrap_or_else(|poisoned| poisoned.into_inner())
7226            .records
7227            .values()
7228            .filter(|record| {
7229                record.state == ManagedSessionState::Live
7230                    && record.exported_context.is_none()
7231                    && record.provider_session.is_some()
7232            })
7233            .filter_map(|record| {
7234                record
7235                    .active_session
7236                    .clone()
7237                    .map(|session| (record.record_id.clone(), session))
7238            })
7239            .collect();
7240        for (record_id, session) in candidates {
7241            let shared = Arc::clone(self);
7242            tokio::spawn(async move {
7243                shared
7244                    .reconcile_context_pack_export_for_record(&record_id, session)
7245                    .await;
7246            });
7247        }
7248    }
7249
7250    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
7251    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
7252    /// round trip this performs (`discover_history`/`load_history`, via
7253    /// `export_context_pack_for_session_record`) can only ever settle once
7254    /// `NativeRuntime::tick()` runs again to drain the authority worker's
7255    /// completion — and `tick()` is exclusively owned by, and only ever
7256    /// called from, that same drive-loop task. Awaiting this inline there
7257    /// would starve `tick()` and this call could never observe its own
7258    /// command complete.
7259    ///
7260    /// Because this now genuinely races the drive loop's own
7261    /// `publish_control` (which downgrades this exact record from `Live` to
7262    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
7263    /// triggered this reconcile), the record legitimately may already be
7264    /// `Dormant` by the time this reads it, or may transition mid-flight —
7265    /// `allow_clean_detachment: true` on the downstream export call is what
7266    /// tolerates exactly that transition (and only that transition; see
7267    /// `session_record_export_target_matches`).
7268    async fn reconcile_context_pack_export_for_record(
7269        &self,
7270        record_id: &SessionRecordId,
7271        session: SessionAddress,
7272    ) {
7273        let Ok(record) = self.record(record_id) else {
7274            return;
7275        };
7276        if !matches!(
7277            record.state,
7278            ManagedSessionState::Live | ManagedSessionState::Dormant
7279        ) || record.exported_context.is_some()
7280            || record.provider_session.is_none()
7281        {
7282            return;
7283        }
7284        // Pack bytes are already durably committed inside this call, via
7285        // commit_context_pack_for_session_record's own durable
7286        // write-through (context_pack_store) — no separate step here.
7287        // Unclean exits (non-zero/forced/no loadable history) are already
7288        // rejected by context_pack_source_status_is_usable inside
7289        // materialize_context_pack: this simply returns Err and is skipped.
7290        let Ok(receipt) = self
7291            .export_context_pack_for_session_record(&record.record_id, &session, true)
7292            .await
7293        else {
7294            return;
7295        };
7296        self.set_exported_context(&record.record_id, receipt);
7297    }
7298
7299    /// Records the record's most recent clean-exit pack. No-op (not an
7300    /// error) if the record disappeared, or if it already carries an
7301    /// `exported_context` — first-write-wins, matching the harness-side
7302    /// idempotent reconciliation that consumes this field.
7303    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7304        let transaction = self
7305            .state_transaction
7306            .lock()
7307            .unwrap_or_else(|poisoned| poisoned.into_inner());
7308        let (previous, updated) = {
7309            let mut records = self
7310                .session_records
7311                .lock()
7312                .unwrap_or_else(|poisoned| poisoned.into_inner());
7313            let Some(record) = records.records.get_mut(record_id) else {
7314                return;
7315            };
7316            if record.exported_context.is_some() {
7317                return;
7318            }
7319            let previous = record.clone();
7320            record.exported_context = Some(receipt);
7321            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7322            (previous, record.clone())
7323        };
7324        if self.persist_state_locked().is_err() {
7325            self.session_records
7326                .lock()
7327                .unwrap_or_else(|poisoned| poisoned.into_inner())
7328                .records
7329                .insert(record_id.clone(), previous);
7330            return;
7331        }
7332        drop(transaction);
7333        self.publish_record(updated);
7334    }
7335
7336    /// Resolves the managed record and its exact `SessionAddress` currently
7337    /// bound to `instance_id` at exactly `generation`, used by the reactive
7338    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
7339    /// record without a full rescan. Mirrors the lookup already inlined in
7340    /// `publish_control`. Captured synchronously, before `publish_control`
7341    /// for the same event clears the record's own `active_session` — the
7342    /// caller passes the resolved address through to a detached
7343    /// reconciliation task rather than re-deriving it from the record later,
7344    /// after that race has necessarily already happened.
7345    fn managed_record_export_target_for_instance(
7346        &self,
7347        instance_id: AgentInstanceId,
7348        generation: SessionGeneration,
7349    ) -> Option<(SessionRecordId, SessionAddress)> {
7350        let bindings = self
7351            .session_bindings
7352            .lock()
7353            .unwrap_or_else(|poisoned| poisoned.into_inner());
7354        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7355        let record_id = binding.record_id.clone()?;
7356        Some((
7357            record_id,
7358            SessionAddress {
7359                workspace_id: binding.workspace_id.clone(),
7360                session: SessionKey { instance_id, generation },
7361            },
7362        ))
7363    }
7364
7365    fn mark_managed_recovery_required(
7366        &self,
7367        lease_id: &ManagedWorktreeLeaseId,
7368        failure_kind: ManagedWorktreeCleanupFailure,
7369    ) {
7370        let snapshot = {
7371            let mut registry = self.managed_worktrees.lock()
7372                .unwrap_or_else(|poisoned| poisoned.into_inner());
7373            let Some(lease) = registry.get_mut(lease_id) else { return };
7374            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7375            lease.cleanup_failure = Some(failure_kind);
7376            lease.updated_at_unix_ms = unix_time_ms();
7377            lease.snapshot()
7378        };
7379        let _ = self.persist_state();
7380        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7381    }
7382
7383    fn set_persistence_error(&self, error: Option<String>) {
7384        *self
7385            .persistence_error
7386            .write()
7387            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7388            .map(|message| session_registry::sanitized_persistence_summary(&message));
7389    }
7390
7391    fn persist_state(&self) -> io::Result<()> {
7392        let _transaction = self
7393            .state_transaction
7394            .lock()
7395            .unwrap_or_else(|poisoned| poisoned.into_inner());
7396        self.persist_state_locked()
7397    }
7398
7399    fn persist_state_locked(&self) -> io::Result<()> {
7400        let workspaces = self
7401            .workspaces
7402            .read()
7403            .unwrap_or_else(|poisoned| poisoned.into_inner())
7404            .clone();
7405        let records = self
7406            .session_records
7407            .lock()
7408            .unwrap_or_else(|poisoned| poisoned.into_inner())
7409            .records
7410            .values()
7411            .cloned()
7412            .collect::<Vec<_>>();
7413        let mut managed = self.managed_worktrees
7414            .lock()
7415            .unwrap_or_else(|poisoned| poisoned.into_inner());
7416        managed.reattach_record_holders(
7417            &managed_worktree_record_holders(&records),
7418            unix_time_ms(),
7419        );
7420        let materializations = self
7421            .materializations
7422            .lock()
7423            .unwrap_or_else(|poisoned| poisoned.into_inner())
7424            .values()
7425            .cloned()
7426            .collect::<Vec<_>>();
7427        let managed_spawn_replays = self.managed_spawn_replays
7428            .lock()
7429            .unwrap_or_else(|poisoned| poisoned.into_inner())
7430            .values()
7431            .cloned()
7432            .collect::<Vec<_>>();
7433        let result = session_registry::save_v10(
7434            self.state_path.as_deref(),
7435            &self.node_id,
7436            &workspaces,
7437            &records,
7438            &managed.records(),
7439            &managed.tombstones(),
7440            &materializations,
7441            &managed_spawn_replays,
7442        );
7443        match &result {
7444            Ok(warning) => self.set_persistence_error(warning.clone()),
7445            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7446        }
7447        result.map(|_| ())
7448    }
7449
7450    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7451        let _mutation_guard = self.mutation_gate.lock().await;
7452        self.begin_shutdown_locked().await
7453    }
7454
7455    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7456        if self.shutdown.swap(true, Ordering::AcqRel) {
7457            return Ok(());
7458        }
7459        SessionLifecycleHook::on_shutdown(self);
7460        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7461        let mut first_error = None;
7462        for session in &self.handle.snapshot().sessions {
7463            let remaining = deadline.saturating_duration_since(Instant::now());
7464            if remaining.is_zero() {
7465                first_error.get_or_insert_with(|| {
7466                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7467                });
7468                break;
7469            }
7470            if let Err(error) = self
7471                .dispatch_bounded(
7472                    ControlCommand::Stop {
7473                        instance_id: session.instance_id,
7474                        force: true,
7475                    },
7476                    remaining,
7477                )
7478                .await
7479            {
7480                first_error.get_or_insert(error);
7481            }
7482        }
7483        self.shutdown_notify.notify_waiters();
7484        match first_error {
7485            Some(error) => Err(error),
7486            None => Ok(()),
7487        }
7488    }
7489
7490    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7491        let bindings = self
7492            .session_bindings
7493            .lock()
7494            .unwrap_or_else(|poisoned| poisoned.into_inner());
7495        if bindings.len() >= CONTROL_SESSIONS_MAX {
7496            Err(failure(
7497                NodeFailureCode::BackendBusy,
7498                "node session-binding capacity is full; remove an existing session first",
7499            ))
7500        } else {
7501            Ok(())
7502        }
7503    }
7504
7505    /// One-owner workspace/env collision guard: a live session on
7506    /// `workspace_id` with a different `environment_profile` (option
7507    /// equality on the resolved receipt, including Some vs None) blocks
7508    /// another spawn on that same cwd. Same profile may still share the
7509    /// workspace; distinct profiles need distinct workspace ids (managed
7510    /// worktree).
7511    fn ensure_workspace_environment_profile_compatible(
7512        &self,
7513        workspace_id: &WorkspaceId,
7514        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7515    ) -> Result<(), NodeFailure> {
7516        let bindings = self
7517            .session_bindings
7518            .lock()
7519            .unwrap_or_else(|poisoned| poisoned.into_inner());
7520        for binding in bindings.values() {
7521            if &binding.workspace_id != workspace_id {
7522                continue;
7523            }
7524            if binding.environment_profile.as_ref() != environment_profile {
7525                return Err(failure(
7526                    NodeFailureCode::WorkspaceBusy,
7527                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7528                ));
7529            }
7530        }
7531        Ok(())
7532    }
7533
7534    #[cfg(test)]
7535    fn bind_session(
7536        &self,
7537        instance_id: AgentInstanceId,
7538        workspace_id: WorkspaceId,
7539        generation: SessionGeneration,
7540    ) {
7541        self.bind_session_with_policy(
7542            &SessionAddress {
7543                workspace_id,
7544                session: SessionKey { instance_id, generation },
7545            },
7546            ProviderRuntimePolicy::raw_pty(),
7547            None,
7548        );
7549    }
7550
7551    #[cfg(test)]
7552    fn bind_session_with_policy(
7553        &self,
7554        address: &SessionAddress,
7555        runtime_policy: ProviderRuntimePolicy,
7556        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7557    ) {
7558        self.bind_session_with_materialization(
7559            address,
7560            runtime_policy,
7561            environment_profile,
7562            None,
7563            None,
7564            None,
7565        );
7566    }
7567
7568    fn bind_session_with_materialization(
7569        &self,
7570        address: &SessionAddress,
7571        runtime_policy: ProviderRuntimePolicy,
7572        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7573        bundle: Option<ResolvedBundleReceipt>,
7574        context: Option<ResolvedContextPackReceipt>,
7575        materialization_id: Option<MaterializationId>,
7576    ) {
7577        let mut bindings = self
7578            .session_bindings
7579            .lock()
7580            .unwrap_or_else(|poisoned| poisoned.into_inner());
7581        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7582        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7583        bindings.insert(address.session.instance_id, SessionBinding {
7584            workspace_id: address.workspace_id.clone(),
7585            generation: address.session.generation,
7586            runtime_policy,
7587            pending_resume: None,
7588            record_id: None,
7589            managed_worktree_lease_id: None,
7590            environment_profile,
7591            bundle,
7592            context,
7593            materialization_id,
7594        });
7595    }
7596
7597    #[cfg(test)]
7598    fn bind_managed_session(
7599        &self,
7600        address: &SessionAddress,
7601        record_id: SessionRecordId,
7602        runtime_policy: ProviderRuntimePolicy,
7603        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7604    ) {
7605        self.bind_managed_session_with_materialization(
7606            address,
7607            record_id,
7608            runtime_policy,
7609            environment_profile,
7610            None,
7611            None,
7612            None,
7613        );
7614    }
7615
7616    fn bind_managed_session_with_materialization(
7617        &self,
7618        address: &SessionAddress,
7619        record_id: SessionRecordId,
7620        runtime_policy: ProviderRuntimePolicy,
7621        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7622        bundle: Option<ResolvedBundleReceipt>,
7623        context: Option<ResolvedContextPackReceipt>,
7624        materialization_id: Option<MaterializationId>,
7625    ) {
7626        let mut bindings = self
7627            .session_bindings
7628            .lock()
7629            .unwrap_or_else(|poisoned| poisoned.into_inner());
7630        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7631        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7632        bindings.insert(
7633            address.session.instance_id,
7634            SessionBinding {
7635                workspace_id: address.workspace_id.clone(),
7636                generation: address.session.generation,
7637                runtime_policy,
7638                pending_resume: None,
7639                record_id: Some(record_id),
7640                managed_worktree_lease_id: None,
7641                environment_profile,
7642                bundle,
7643                context,
7644                materialization_id,
7645            },
7646        );
7647    }
7648
7649    #[cfg(test)]
7650    fn bind_spawn_session(
7651        &self,
7652        address: &SessionAddress,
7653        provider: AgentId,
7654        mode: SessionMode,
7655        runtime_policy: ProviderRuntimePolicy,
7656        record_policy: SpawnRecordPolicy,
7657        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7658    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7659        self.bind_spawn_session_with_materialization(
7660            address,
7661            provider,
7662            mode,
7663            runtime_policy,
7664            record_policy,
7665            environment_profile,
7666            None,
7667            None,
7668            None,
7669        )
7670    }
7671
7672    fn bind_spawn_session_with_materialization(
7673        &self,
7674        address: &SessionAddress,
7675        provider: AgentId,
7676        mode: SessionMode,
7677        runtime_policy: ProviderRuntimePolicy,
7678        record_policy: SpawnRecordPolicy,
7679        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7680        bundle: Option<ResolvedBundleReceipt>,
7681        context: Option<ResolvedContextPackReceipt>,
7682        materialization_id: Option<MaterializationId>,
7683    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7684        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7685            && !runtime_policy.provider_session_identity
7686        {
7687            self.bind_session_with_materialization(
7688                address,
7689                runtime_policy,
7690                environment_profile,
7691                bundle,
7692                context,
7693                materialization_id,
7694            );
7695            return Ok(None);
7696        }
7697        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7698        let record = self.new_record(
7699            address,
7700            provider,
7701            mode,
7702            active_record_state(runtime_policy.provider_session_identity, false),
7703            None,
7704            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7705            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7706            record_owns_lifecycle.then(|| context.clone()).flatten(),
7707        )?;
7708        let record_id = record.record_id.clone();
7709        let transaction = self
7710            .state_transaction
7711            .lock()
7712            .unwrap_or_else(|poisoned| poisoned.into_inner());
7713        self.insert_record(record.clone())?;
7714        let previous_materialization = if record_owns_lifecycle {
7715            if let Some(materialization_id) = materialization_id.as_ref() {
7716                let mut materializations = self
7717                    .materializations
7718                    .lock()
7719                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7720                let Some(ownership) = materializations.get_mut(materialization_id) else {
7721                    drop(materializations);
7722                    self.remove_record_memory(&record_id);
7723                    return Err(failure(
7724                        NodeFailureCode::BackendOperationFailed,
7725                        "session environment ownership disappeared before binding",
7726                    ));
7727                };
7728                let previous = ownership.clone();
7729                if ownership
7730                    .transfer_to_record(record_id.clone(), unix_time_ms())
7731                    .is_err()
7732                {
7733                    drop(materializations);
7734                    self.remove_record_memory(&record_id);
7735                    return Err(failure(
7736                        NodeFailureCode::BackendOperationFailed,
7737                        "session environment ownership transfer failed",
7738                    ));
7739                }
7740                Some(previous)
7741            } else {
7742                None
7743            }
7744        } else {
7745            None
7746        };
7747        self.bind_managed_session_with_materialization(
7748            address,
7749            record_id.clone(),
7750            runtime_policy,
7751            environment_profile,
7752            bundle,
7753            context,
7754            materialization_id.clone(),
7755        );
7756        if let Err(error) = self.persist_state_locked() {
7757            self.remove_binding(address);
7758            self.remove_record_memory(&record_id);
7759            if let (Some(materialization_id), Some(previous)) =
7760                (materialization_id.as_ref(), previous_materialization)
7761            {
7762                self.materializations
7763                    .lock()
7764                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7765                    .insert(materialization_id.clone(), previous);
7766            }
7767            return Err(persistence_failure(error));
7768        }
7769        drop(transaction);
7770        self.publish_record(record);
7771        Ok(Some(record_id))
7772    }
7773
7774    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7775        for _ in 0..8 {
7776            let bytes = random_nonce().map_err(|error| {
7777                failure(
7778                    NodeFailureCode::BackendOperationFailed,
7779                    &format!("session record identity generation failed: {error}"),
7780                )
7781            })?;
7782            let mut value = String::with_capacity(27);
7783            value.push_str("sr-");
7784            for byte in &bytes[..12] {
7785                use std::fmt::Write as _;
7786                let _ = write!(&mut value, "{byte:02x}");
7787            }
7788            let record_id = SessionRecordId::new(value).map_err(|error| {
7789                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7790            })?;
7791            let records = self
7792                .session_records
7793                .lock()
7794                .unwrap_or_else(|poisoned| poisoned.into_inner());
7795            if !records.records.contains_key(&record_id) {
7796                return Ok(record_id);
7797            }
7798        }
7799        Err(failure(
7800            NodeFailureCode::BackendBusy,
7801            "could not allocate a unique session record ID",
7802        ))
7803    }
7804
7805    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7806        for _ in 0..8 {
7807            let bytes = random_nonce().map_err(|error| {
7808                failure(
7809                    NodeFailureCode::BackendOperationFailed,
7810                    &format!("task identity generation failed: {error}"),
7811                )
7812            })?;
7813            let mut nonce = [0_u8; 12];
7814            nonce.copy_from_slice(&bytes[..12]);
7815            let task_id = TaskId::from_nonce(nonce);
7816            let records = self
7817                .session_records
7818                .lock()
7819                .unwrap_or_else(|poisoned| poisoned.into_inner());
7820            if !records.records.values().any(|record| {
7821                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7822                    == Some(&task_id)
7823            }) {
7824                return Ok(task_id);
7825            }
7826        }
7827        Err(failure(
7828            NodeFailureCode::BackendBusy,
7829            "could not allocate a unique task ID",
7830        ))
7831    }
7832
7833    fn default_record_name(&self, provider: &AgentId) -> String {
7834        let records = self
7835            .session_records
7836            .lock()
7837            .unwrap_or_else(|poisoned| poisoned.into_inner());
7838        let ordinal = records
7839            .records
7840            .values()
7841            .filter(|record| &record.provider == provider)
7842            .count()
7843            .saturating_add(1);
7844        format!("{} #{ordinal}", provider.as_str())
7845    }
7846
7847    fn new_record(
7848        &self,
7849        address: &SessionAddress,
7850        provider: AgentId,
7851        mode: SessionMode,
7852        state: ManagedSessionState,
7853        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7854        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7855        bundle: Option<ResolvedBundleReceipt>,
7856        context: Option<ResolvedContextPackReceipt>,
7857    ) -> Result<ManagedSessionRecord, NodeFailure> {
7858        let canonical_root = self.workspace_root(&address.workspace_id)?;
7859        let now = unix_time_ms();
7860        Ok(ManagedSessionRecord {
7861            record_id: self.allocate_record_id()?,
7862            display_name: self.m4a_nick_of(address.session.instance_id).unwrap_or_else(|| self.default_record_name(&provider)),
7863            provider,
7864            mode,
7865            state,
7866            workspace_id: address.workspace_id.clone(),
7867            canonical_root: opaque_windows_path(canonical_root),
7868            provider_session,
7869            active_session: Some(address.clone()),
7870            environment_profile,
7871            bundle,
7872            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7873            context,
7874            exported_context: None,
7875            task_binding: None,
7876            created_at_unix_ms: now,
7877            updated_at_unix_ms: now,
7878            last_error: None,
7879        })
7880    }
7881
7882    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7883        let mut records = self
7884            .session_records
7885            .lock()
7886            .unwrap_or_else(|poisoned| poisoned.into_inner());
7887        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7888            return Err(failure(
7889                NodeFailureCode::BackendBusy,
7890                "managed session record capacity is full; forget an inactive record first",
7891            ));
7892        }
7893        if let Some(identity) = record.provider_session.as_ref() {
7894            if records.records.values().any(|candidate| {
7895                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7896                    session_registry::same_provider_session(
7897                        &candidate.provider,
7898                        candidate_identity,
7899                        &record.provider,
7900                        identity,
7901                    )
7902                })
7903            }) {
7904                return Err(failure(
7905                    NodeFailureCode::SessionRecordConflict,
7906                    "provider session is already represented by another managed record",
7907                ));
7908            }
7909        }
7910        match records.records.entry(record.record_id.clone()) {
7911            std::collections::btree_map::Entry::Vacant(entry) => {
7912                entry.insert(record);
7913            }
7914            std::collections::btree_map::Entry::Occupied(_) => {
7915                return Err(failure(
7916                    NodeFailureCode::SessionRecordConflict,
7917                    "session record ID collision",
7918                ));
7919            }
7920        }
7921        Ok(())
7922    }
7923
7924    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7925        self.session_records
7926            .lock()
7927            .unwrap_or_else(|poisoned| poisoned.into_inner())
7928            .records
7929            .get(record_id)
7930            .cloned()
7931            .ok_or_else(|| {
7932                failure(
7933                    NodeFailureCode::UnknownSessionRecord,
7934                    "managed session record does not exist",
7935                )
7936            })
7937    }
7938
7939    fn revalidate_session_record_preview(
7940        &self,
7941        expected: &ManagedSessionRecord,
7942        identity: &ProviderSessionIdentity,
7943    ) -> Result<(), NodeFailure> {
7944        let current = self.record(&expected.record_id)?;
7945        let current_root = self.workspace_root(&current.workspace_id)?;
7946        if current.provider != expected.provider
7947            || current.workspace_id != expected.workspace_id
7948            || current.canonical_root != expected.canonical_root
7949            || current.provider_session.as_ref() != Some(identity)
7950            || !platform::roots_equal(
7951                &current_root,
7952                windows_path_text(&current.canonical_root),
7953            )
7954        {
7955            return Err(failure(
7956                NodeFailureCode::SessionRecordConflict,
7957                "managed session changed while its preview was loading",
7958            ));
7959        }
7960        Ok(())
7961    }
7962
7963    fn revalidate_session_record_context_export(
7964        &self,
7965        expected: &ManagedSessionRecord,
7966        identity: Option<&ProviderSessionIdentity>,
7967        session: &SessionAddress,
7968        allow_clean_detachment: bool,
7969    ) -> Result<(), NodeFailure> {
7970        let current = self.record(&expected.record_id)?;
7971        let current_root = self.workspace_root(&current.workspace_id)?;
7972        let snapshot = self.internal_session_snapshot(session)?;
7973        let binding = self
7974            .session_bindings
7975            .lock()
7976            .unwrap_or_else(|poisoned| poisoned.into_inner())
7977            .get(&session.session.instance_id)
7978            .cloned()
7979            .ok_or_else(|| {
7980                failure(
7981                    NodeFailureCode::SessionRecordConflict,
7982                    "managed session runtime binding disappeared during context export",
7983                )
7984            })?;
7985        if !session_record_context_export_binding_is_exact(
7986            expected,
7987            &current,
7988            identity,
7989            session,
7990            &binding,
7991            &snapshot.agent_id,
7992            &current_root,
7993            allow_clean_detachment,
7994        ) || !session_record_context_export_source_is_usable(
7995            &current,
7996            &snapshot.status,
7997            allow_clean_detachment,
7998        ) {
7999            return Err(failure(
8000                NodeFailureCode::SessionRecordConflict,
8001                "managed session binding changed during context export",
8002            ));
8003        }
8004        Ok(())
8005    }
8006
8007    fn revalidate_loaded_history(
8008        &self,
8009        session: &SessionAddress,
8010        candidate_id: &str,
8011        loaded: &HistorySessionRecord,
8012    ) -> Result<(), NodeFailure> {
8013        let snapshot = self.internal_session_snapshot(session)?;
8014        if snapshot.history.pending.is_some()
8015            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
8016            || snapshot.history.loaded.as_ref() != Some(loaded)
8017        {
8018            return Err(failure(
8019                NodeFailureCode::SessionRecordConflict,
8020                "managed session history changed during context export",
8021            ));
8022        }
8023        Ok(())
8024    }
8025
8026    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
8027        self.session_records
8028            .lock()
8029            .unwrap_or_else(|poisoned| poisoned.into_inner())
8030            .records
8031            .remove(record_id)
8032    }
8033
8034    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
8035        if self.destroy_record_materialization(record_id)? {
8036            self.publish(NodeEvent::SessionRecordRemoved {
8037                record_id: record_id.clone(),
8038            });
8039            return Ok(());
8040        }
8041        let transaction = self
8042            .state_transaction
8043            .lock()
8044            .unwrap_or_else(|poisoned| poisoned.into_inner());
8045        let Some(removed) = self.remove_record_memory(record_id) else {
8046            return Ok(());
8047        };
8048        if let Err(error) = self.persist_state_locked() {
8049            let _ = self.insert_record(removed);
8050            return Err(persistence_failure(error));
8051        }
8052        drop(transaction);
8053        self.publish(NodeEvent::SessionRecordRemoved {
8054            record_id: record_id.clone(),
8055        });
8056        Ok(())
8057    }
8058
8059    fn publish_record(&self, record: ManagedSessionRecord) {
8060        self.publish(NodeEvent::SessionRecordUpserted { record });
8061    }
8062
8063    fn mark_record_error(
8064        &self,
8065        record_id: &SessionRecordId,
8066        message: &str,
8067    ) -> Result<(), NodeFailure> {
8068        let transaction = self
8069            .state_transaction
8070            .lock()
8071            .unwrap_or_else(|poisoned| poisoned.into_inner());
8072        let previous = {
8073            let mut records = self
8074                .session_records
8075                .lock()
8076                .unwrap_or_else(|poisoned| poisoned.into_inner());
8077            let Some(record) = records.records.get_mut(record_id) else {
8078                return Ok(());
8079            };
8080            let previous = record.clone();
8081            record.state = ManagedSessionState::Unavailable;
8082            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8083            record.updated_at_unix_ms = unix_time_ms();
8084            previous
8085        };
8086        if let Err(error) = self.persist_state_locked() {
8087            self.session_records
8088                .lock()
8089                .unwrap_or_else(|poisoned| poisoned.into_inner())
8090                .records
8091                .insert(record_id.clone(), previous);
8092            return Err(persistence_failure(error));
8093        }
8094        let record = self.record(record_id)?;
8095        drop(transaction);
8096        self.publish_record(record);
8097        Ok(())
8098    }
8099
8100    fn rename_session_record(
8101        &self,
8102        record_id: &SessionRecordId,
8103        display_name: String,
8104    ) -> Result<ManagedSessionRecord, NodeFailure> {
8105        validate_display_name(&display_name)
8106            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8107        let _transaction = self
8108            .state_transaction
8109            .lock()
8110            .unwrap_or_else(|poisoned| poisoned.into_inner());
8111        let previous = {
8112            let mut records = self
8113                .session_records
8114                .lock()
8115                .unwrap_or_else(|poisoned| poisoned.into_inner());
8116            let record = records.records.get_mut(record_id).ok_or_else(|| {
8117                failure(
8118                    NodeFailureCode::UnknownSessionRecord,
8119                    "managed session record does not exist",
8120                )
8121            })?;
8122            let previous = record.clone();
8123            record.display_name = display_name;
8124            record.updated_at_unix_ms = unix_time_ms();
8125            previous
8126        };
8127        if let Err(error) = self.persist_state_locked() {
8128            self.session_records
8129                .lock()
8130                .unwrap_or_else(|poisoned| poisoned.into_inner())
8131                .records
8132                .insert(record_id.clone(), previous);
8133            return Err(persistence_failure(error));
8134        }
8135        let updated = self.record(record_id)?;
8136        self.publish_record(updated.clone());
8137        Ok(updated)
8138    }
8139
8140    fn set_session_task(
8141        &self,
8142        record_id: &SessionRecordId,
8143        expected_revision: u64,
8144        target: SessionTaskTargetV1,
8145    ) -> Result<ManagedSessionRecord, NodeFailure> {
8146        let transaction = self
8147            .state_transaction
8148            .lock()
8149            .unwrap_or_else(|poisoned| poisoned.into_inner());
8150        let current = self.record(record_id)?;
8151        let current_task_id = current
8152            .task_binding
8153            .as_ref()
8154            .and_then(|binding| binding.task_id.as_ref());
8155        let current_revision = current
8156            .task_binding
8157            .as_ref()
8158            .map_or(0, |binding| binding.revision);
8159        if expected_revision != current_revision {
8160            return Err(failure(
8161                NodeFailureCode::SessionRecordConflict,
8162                "managed session task binding revision changed",
8163            ));
8164        }
8165        let exact = match &target {
8166            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8167            SessionTaskTargetV1::Clear => current_task_id.is_none(),
8168            SessionTaskTargetV1::New => false,
8169        };
8170        if exact {
8171            return Ok(current);
8172        }
8173        let revision = current_revision.checked_add(1).ok_or_else(|| {
8174            failure(
8175                NodeFailureCode::SessionRecordConflict,
8176                "managed session task binding revision is exhausted",
8177            )
8178        })?;
8179        let task_id = match target {
8180            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8181            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8182            SessionTaskTargetV1::Clear => None,
8183        };
8184        let changed_at_unix_ms = unix_time_ms()
8185            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8186                failure(
8187                    NodeFailureCode::SessionRecordConflict,
8188                    "managed session record timestamp is exhausted",
8189                )
8190            })?);
8191        let updated = {
8192            let mut records = self
8193                .session_records
8194                .lock()
8195                .unwrap_or_else(|poisoned| poisoned.into_inner());
8196            let record = records.records.get_mut(record_id).ok_or_else(|| {
8197                failure(
8198                    NodeFailureCode::UnknownSessionRecord,
8199                    "managed session record does not exist",
8200                )
8201            })?;
8202            record.task_binding = Some(SessionTaskBindingV1 {
8203                revision,
8204                task_id,
8205                changed_at_unix_ms,
8206            });
8207            record.updated_at_unix_ms = changed_at_unix_ms;
8208            record.clone()
8209        };
8210        if let Err(error) = self.persist_state_locked() {
8211            self.session_records
8212                .lock()
8213                .unwrap_or_else(|poisoned| poisoned.into_inner())
8214                .records
8215                .insert(record_id.clone(), current);
8216            return Err(persistence_failure(error));
8217        }
8218        drop(transaction);
8219        self.publish_record(updated.clone());
8220        Ok(updated)
8221    }
8222
8223    fn index_provider_session(
8224        &self,
8225        workspace_id: WorkspaceId,
8226        provider: AgentId,
8227        identity: ProviderSessionIdentity,
8228        display_name: String,
8229    ) -> Result<ManagedSessionRecord, NodeFailure> {
8230        self.index_provider_session_with_policy(
8231            workspace_id,
8232            provider,
8233            identity,
8234            display_name,
8235            false,
8236        )
8237    }
8238
8239    fn index_provider_session_with_policy(
8240        &self,
8241        workspace_id: WorkspaceId,
8242        provider: AgentId,
8243        identity: ProviderSessionIdentity,
8244        display_name: String,
8245        allow_validated_transcript_path: bool,
8246    ) -> Result<ManagedSessionRecord, NodeFailure> {
8247        validate_display_name(&display_name)
8248            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8249        identity
8250            .validate()
8251            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8252        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8253            return Err(failure(
8254                NodeFailureCode::InvalidRequest,
8255                "provider session reference index v1 does not accept transcript paths",
8256            ));
8257        }
8258        let canonical_root = self.workspace_root(&workspace_id)?;
8259        if !self.enabled_providers.contains(&provider) {
8260            return Err(failure(
8261                NodeFailureCode::InvalidRequest,
8262                "provider is not enabled on this node",
8263            ));
8264        }
8265
8266        let transaction = self
8267            .state_transaction
8268            .lock()
8269            .unwrap_or_else(|poisoned| poisoned.into_inner());
8270        {
8271            let records = self
8272                .session_records
8273                .lock()
8274                .unwrap_or_else(|poisoned| poisoned.into_inner());
8275            if let Some(existing) = records.records.values().find(|candidate| {
8276                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8277                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
8278                })
8279            }) {
8280                if existing.provider == provider && existing.workspace_id == workspace_id {
8281                    return Ok(existing.clone());
8282                }
8283                return Err(failure(
8284                    NodeFailureCode::SessionRecordConflict,
8285                    "provider session reference conflicts with another provider or workspace",
8286                ));
8287            }
8288            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8289                return Err(failure(
8290                    NodeFailureCode::BackendBusy,
8291                    "managed session record capacity is full; forget an inactive record first",
8292                ));
8293            }
8294        }
8295
8296        let now = unix_time_ms();
8297        let record = ManagedSessionRecord {
8298            record_id: self.allocate_record_id()?,
8299            display_name,
8300            provider,
8301            mode: SessionMode::Pty,
8302            state: ManagedSessionState::Dormant,
8303            workspace_id,
8304            canonical_root: opaque_windows_path(canonical_root),
8305            provider_session: Some(identity),
8306            active_session: None,
8307            environment_profile: None,
8308            bundle: None,
8309            context_id: None,
8310            context: None,
8311            exported_context: None,
8312            task_binding: None,
8313            created_at_unix_ms: now,
8314            updated_at_unix_ms: now,
8315            last_error: None,
8316        };
8317        self.insert_record(record.clone())?;
8318        if let Err(error) = self.persist_state_locked() {
8319            self.remove_record_memory(&record.record_id);
8320            return Err(persistence_failure(error));
8321        }
8322        drop(transaction);
8323        self.publish_record(record.clone());
8324        Ok(record)
8325    }
8326
8327    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8328        self.session_bindings
8329            .lock()
8330            .unwrap_or_else(|poisoned| poisoned.into_inner())
8331            .iter()
8332            .find_map(|(instance_id, binding)| {
8333                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8334                    workspace_id: binding.workspace_id.clone(),
8335                    session: SessionKey {
8336                        instance_id: *instance_id,
8337                        generation: binding.generation,
8338                    },
8339                })
8340            })
8341    }
8342
8343    async fn forget_session_record(
8344        &self,
8345        record_id: &SessionRecordId,
8346    ) -> Result<(), NodeFailure> {
8347        let record = self.record(record_id)?;
8348        if record.active_session.is_some()
8349            || matches!(
8350                record.state,
8351                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8352            )
8353        {
8354            return Err(failure(
8355                NodeFailureCode::SessionRecordBusy,
8356                "stop the managed session before forgetting it",
8357            ));
8358        }
8359        if let Some(address) = self.bound_address_for_record(record_id) {
8360            self.remove_session(&address).await?;
8361        }
8362        self.remove_managed_session_record(record_id, &record.workspace_id).await
8363    }
8364
8365    /// Removes one managed session record from durable state and notifies
8366    /// every listener -- the shared transaction body behind both
8367    /// `forget_session_record`'s operator-driven path (busy-checked above
8368    /// it, and clears any still-live session binding before calling in
8369    /// here) and the retention sweep (`retire_unavailable_session_records`,
8370    /// which only ever selects `ManagedSessionState::Unavailable` records
8371    /// that are already known never-busy and never bound, so it calls
8372    /// straight into this with neither check). Cleans a managed-worktree
8373    /// lease if the record held one, removes the record's environment
8374    /// materialization (or, absent one, removes it from memory directly),
8375    /// persists under `state_transaction` with rollback on persist failure,
8376    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8377    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8378    /// exactly as it does for an operator-driven forget.
8379    async fn remove_managed_session_record(
8380        &self,
8381        record_id: &SessionRecordId,
8382        workspace_id: &WorkspaceId,
8383    ) -> Result<(), NodeFailure> {
8384        let managed_lease_id = self.managed_worktrees.lock()
8385            .unwrap_or_else(|poisoned| poisoned.into_inner())
8386            .lease_for_workspace(workspace_id);
8387        if !self.destroy_record_materialization(record_id)? {
8388            let transaction = self
8389                .state_transaction
8390                .lock()
8391                .unwrap_or_else(|poisoned| poisoned.into_inner());
8392            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8393                failure(
8394                    NodeFailureCode::UnknownSessionRecord,
8395                    "managed session record does not exist",
8396                )
8397            })?;
8398            if let Err(error) = self.persist_state_locked() {
8399                let _ = self.insert_record(removed);
8400                return Err(persistence_failure(error));
8401            }
8402            drop(transaction);
8403        }
8404        self.publish(NodeEvent::SessionRecordRemoved {
8405            record_id: record_id.clone(),
8406        });
8407        if let Some(lease_id) = managed_lease_id {
8408            if let Some(snapshot) = self.managed_worktrees.lock()
8409                .unwrap_or_else(|poisoned| poisoned.into_inner())
8410                .get(&lease_id)
8411                .map(ManagedWorktreeLeaseRecord::snapshot)
8412            {
8413                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8414            }
8415            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8416        }
8417        Ok(())
8418    }
8419
8420    /// Retires `ManagedSessionState::Unavailable` records against the
8421    /// node's configured `session_record_retention` age/keep-N policy.
8422    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8423    /// `Dormant` is resumable and folding it into this sweep would delete
8424    /// records a caller can still act on. Both policy fields default to
8425    /// `0` (disabled); when both are `0` this is a no-op. When both are
8426    /// set, a record is retired only if it fails BOTH tests (older than
8427    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8428    /// workspace); when only one is set, that one alone decides. Called
8429    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8430    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8431    /// loop -- see both call sites.
8432    async fn retire_unavailable_session_records(&self) {
8433        let policy = self.session_record_retention;
8434        let age_enabled = policy.age_ms > 0;
8435        let keep_enabled = policy.keep_per_workspace > 0;
8436        if !age_enabled && !keep_enabled {
8437            return;
8438        }
8439        let now = unix_time_ms();
8440        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8441            self.session_records
8442                .lock()
8443                .unwrap_or_else(|poisoned| poisoned.into_inner())
8444                .records
8445                .values()
8446                .filter(|record| record.state == ManagedSessionState::Unavailable)
8447                .map(|record| {
8448                    (
8449                        record.record_id.clone(),
8450                        record.workspace_id.clone(),
8451                        record.provider.clone(),
8452                        record.updated_at_unix_ms,
8453                    )
8454                })
8455                .collect()
8456        };
8457        if candidates.is_empty() {
8458            return;
8459        }
8460        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8461            BTreeMap::new();
8462        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8463            by_workspace
8464                .entry(workspace_id)
8465                .or_default()
8466                .push((record_id, provider, updated_at_unix_ms));
8467        }
8468        let reason = if age_enabled && keep_enabled {
8469            "age+keep-n"
8470        } else if age_enabled {
8471            "age"
8472        } else {
8473            "keep-n"
8474        };
8475        let mut retired: u64 = 0;
8476        for (workspace_id, mut records) in by_workspace {
8477            // Newest `updated_at_unix_ms` first, so index `>=
8478            // keep_per_workspace` names exactly "beyond the newest N".
8479            records.sort_by(|left, right| right.2.cmp(&left.2));
8480            for (index, (record_id, provider, updated_at_unix_ms)) in
8481                records.into_iter().enumerate()
8482            {
8483                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8484                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8485                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8486                let should_retire = if age_enabled && keep_enabled {
8487                    age_hit && keep_hit
8488                } else {
8489                    age_hit || keep_hit
8490                };
8491                if !should_retire {
8492                    continue;
8493                }
8494                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8495                    Ok(()) => {
8496                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8497                        retired += 1;
8498                        tracing::info!(
8499                            node_id = %self.node_id,
8500                            record_id = %record_id,
8501                            workspace_id = %workspace_id,
8502                            provider = %provider,
8503                            age_ms = record_age_ms,
8504                            reason,
8505                            "managed session record retired"
8506                        );
8507                    }
8508                    Err(error) => tracing::warn!(
8509                        node_id = %self.node_id,
8510                        record_id = %record_id,
8511                        workspace_id = %workspace_id,
8512                        code = ?error.code,
8513                        cause = %error.message,
8514                        "session record retention sweep failed to retire a record"
8515                    ),
8516                }
8517            }
8518        }
8519        if retired > 0 {
8520            let remaining_unavailable = self
8521                .session_records
8522                .lock()
8523                .unwrap_or_else(|poisoned| poisoned.into_inner())
8524                .records
8525                .values()
8526                .filter(|record| record.state == ManagedSessionState::Unavailable)
8527                .count();
8528            tracing::info!(
8529                node_id = %self.node_id,
8530                retired,
8531                remaining_unavailable,
8532                "session record retention sweep completed"
8533            );
8534        }
8535    }
8536
8537    async fn resume_session_record(
8538        &self,
8539        record_id: &SessionRecordId,
8540        terminal_size: gate4agent_types::TerminalSize,
8541        initial_prompt: Option<String>,
8542    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8543        let record = self.record(record_id)?;
8544        if record.active_session.is_some()
8545            || matches!(
8546                record.state,
8547                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8548            )
8549        {
8550            return Err(failure(
8551                NodeFailureCode::SessionRecordBusy,
8552                "managed session already has a live runtime binding",
8553            ));
8554        }
8555        let identity = record.provider_session.clone().ok_or_else(|| {
8556            failure(
8557                NodeFailureCode::SessionRecordNotResumable,
8558                "managed session has no verified provider session identity",
8559            )
8560        })?;
8561        let working_directory = self.workspace_root(&record.workspace_id)?;
8562        if !platform::roots_equal(
8563            &working_directory,
8564            windows_path_text(&record.canonical_root),
8565        ) {
8566            return Err(failure(
8567                NodeFailureCode::SessionRecordConflict,
8568                "managed session workspace root changed; refusing to resume in another directory",
8569            ));
8570        }
8571        {
8572            let records = self
8573                .session_records
8574                .lock()
8575                .unwrap_or_else(|poisoned| poisoned.into_inner());
8576            if records.records.values().any(|candidate| {
8577                candidate.record_id != *record_id
8578                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8579                        session_registry::same_provider_session(
8580                            &candidate.provider,
8581                            candidate_identity,
8582                            &record.provider,
8583                            &identity,
8584                        )
8585                    })
8586                    && (candidate.active_session.is_some()
8587                        || matches!(
8588                            candidate.state,
8589                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8590                        ))
8591            }) {
8592                return Err(failure(
8593                    NodeFailureCode::SessionRecordConflict,
8594                    "provider session is already active through another managed record",
8595                ));
8596            }
8597        }
8598        let runtime_requirement = if initial_prompt.is_some() {
8599            ProviderRuntimeRequirement::ResumeWithPrompt
8600        } else {
8601            ProviderRuntimeRequirement::Resume
8602        };
8603        let runtime_policy = self.admit_provider_runtime(
8604            &record.provider,
8605            runtime_requirement,
8606        )
8607        .await?;
8608        // The resumed runtime runs in the session folder its record was named after.
8609        let working_directory = match self.m4a_accounts.as_ref() {
8610            Some(_) => {
8611                let dir = std::path::Path::new(&working_directory).join(".hatchery-sessions").join(&record.display_name);
8612                if !record.display_name.contains(' ') && dir.is_dir() { dir.to_string_lossy().into_owned() } else { working_directory }
8613            }
8614            None => working_directory,
8615        };
8616        let request = ResumeLaunchRequest {
8617            working_directory,
8618            terminal_size,
8619            initial_prompt,
8620        };
8621        request
8622            .validate()
8623            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8624        self.ensure_binding_capacity()?;
8625        let resolved_materialization = match self.resolve_record_materialization(
8626            record_id,
8627            &record.provider,
8628            record.mode,
8629            record.environment_profile.as_ref(),
8630            record.bundle.as_ref(),
8631            record.context.as_ref(),
8632        ) {
8633            Ok(materialization) => materialization,
8634            Err(error) => {
8635                let label = match error.code {
8636                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8637                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8638                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8639                    _ => "environment-profile-unavailable",
8640                };
8641                self.mark_record_error(record_id, label)?;
8642                return Err(error);
8643            }
8644        };
8645        let (materialization_id, environment_overlay) = match resolved_materialization {
8646            Some((id, overlay)) => (Some(id), overlay),
8647            None => (None, None),
8648        };
8649        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8650        // The resumed runtime keeps the m4a account of the runtime it replaces.
8651        let environment_overlay = match (self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(record.provider.as_str())) {
8652            (Some(accounts), Some(m4a_provider)) if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) => {
8653                if let Some(old) = self.bound_address_for_record(record_id) {
8654                    if let Some(key) = self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&old.session.instance_id).cloned() {
8655                        self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key);
8656                    }
8657                }
8658                let session_key = self.m4a_session_key(instance_id);
8659                self.m4a_session_overlay(accounts, m4a_provider, &record.provider, instance_id, environment_overlay, &session_key)?
8660            }
8661            _ => environment_overlay,
8662        };
8663        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8664            instance_id,
8665            &record.provider,
8666            record.mode,
8667            record.environment_profile.as_ref(),
8668        ) {
8669            Ok(selection) => selection,
8670            Err(error) => {
8671                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8672                return Err(error);
8673            }
8674        };
8675        let mut instance_overlay = environment_overlay
8676            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8677            .transpose()?
8678            .flatten();
8679        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8680            self.remove_session(&stale_address).await?;
8681        }
8682        let address = SessionAddress {
8683            workspace_id: record.workspace_id.clone(),
8684            session: SessionKey {
8685                instance_id,
8686                generation: SessionGeneration(1),
8687            },
8688        };
8689        let transaction = self
8690            .state_transaction
8691            .lock()
8692            .unwrap_or_else(|poisoned| poisoned.into_inner());
8693        let current = self.record(record_id)?;
8694        if current.active_session.is_some()
8695            || matches!(
8696                current.state,
8697                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8698            )
8699        {
8700            return Err(failure(
8701                NodeFailureCode::SessionRecordBusy,
8702                "managed session became active while resume was being prepared",
8703            ));
8704        }
8705        self.bind_managed_session_with_materialization(
8706            &address,
8707            record_id.clone(),
8708            runtime_policy,
8709            record.environment_profile.clone(),
8710            record.bundle.clone(),
8711            record.context.clone(),
8712            materialization_id,
8713        );
8714        if let Some(selection) = environment_selection.take() {
8715            selection.retain();
8716        }
8717        if let Some(overlay) = instance_overlay.take() {
8718            overlay.retain();
8719        }
8720        drop(transaction);
8721
8722        let transport = match record.mode {
8723            SessionMode::Pty => TransportKind::Pty,
8724            SessionMode::Inline => TransportKind::Pipe,
8725            SessionMode::Acp => TransportKind::Acp,
8726        };
8727        let agent_id = record.provider.clone();
8728        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8729        if let Err(error) = self
8730            .dispatch_bounded(
8731                ControlCommand::Register {
8732                    instance_id,
8733                    agent_id,
8734                    transport,
8735                },
8736                dispatch_timeout,
8737            )
8738            .await
8739        {
8740            if let Some(binding) = self.remove_binding(&address) {
8741                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8742            }
8743            return Err(error);
8744        }
8745        let command = self.prepare_command(ControlCommand::Resume {
8746            instance_id,
8747            target: ResumeTarget::ProviderSession { identity },
8748            runtime_policy,
8749            request,
8750        });
8751        let started_after = self.current_sequence();
8752        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8753            self.rollback_spawn(&address, dispatch_timeout).await?;
8754            return Err(error);
8755        }
8756        if let Err(error) = self.dispatch_envelope(command) {
8757            self.clear_armed_resume(&address);
8758            self.rollback_spawn(&address, dispatch_timeout).await?;
8759            return Err(error);
8760        }
8761        match self
8762            .wait_for_record_resume(&address, started_after)
8763            .await
8764        {
8765            Ok(settled_address) => {
8766                let managed_lease_id = {
8767                    self.managed_worktrees.lock()
8768                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8769                        .lease_for_workspace(&record.workspace_id)
8770                };
8771                if let Some(lease_id) = managed_lease_id {
8772                    {
8773                        let mut bindings = self.session_bindings.lock()
8774                            .unwrap_or_else(|poisoned| poisoned.into_inner());
8775                        let binding = bindings.get_mut(&settled_address.session.instance_id)
8776                            .ok_or_else(|| failure(
8777                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8778                                "resumed managed session lost its exact binding",
8779                            ))?;
8780                        if binding.generation != settled_address.session.generation {
8781                            return Err(failure(
8782                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8783                                "resumed managed session generation diverged",
8784                            ));
8785                        }
8786                        binding.managed_worktree_lease_id = Some(lease_id.clone());
8787                    }
8788                    let snapshot = self.managed_worktrees.lock()
8789                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8790                        .bind_session(
8791                            &lease_id,
8792                            ManagedWorktreeSessionHolder {
8793                                incarnation_id: self.incarnation_id,
8794                                instance_id: settled_address.session.instance_id,
8795                                generation: settled_address.session.generation,
8796                            },
8797                            Some(record_id.clone()),
8798                            unix_time_ms(),
8799                        ).map_err(|message| failure(
8800                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
8801                            &message,
8802                        ))?;
8803                    if let Err(error) = self.persist_state() {
8804                        let _ = self.remove_session(&settled_address).await;
8805                        return Err(persistence_failure(error));
8806                    }
8807                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8808                }
8809                Ok((self.record(record_id)?, settled_address))
8810            }
8811            Err(error) => {
8812                if matches!(
8813                    error.code,
8814                    NodeFailureCode::SessionRecordNotResumable
8815                        | NodeFailureCode::BackendOperationFailed
8816                ) {
8817                    if let Some(bound) = self.bound_address_for_record(record_id) {
8818                        let _ = self.remove_session(&bound).await;
8819                    }
8820                }
8821                Err(error)
8822            }
8823        }
8824    }
8825
8826    async fn wait_for_record_resume(
8827        &self,
8828        initial_address: &SessionAddress,
8829        after_sequence: u64,
8830    ) -> Result<SessionAddress, NodeFailure> {
8831        let deadline = Instant::now()
8832            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8833        let mut scan_after = after_sequence;
8834        loop {
8835            let events = self
8836                .history
8837                .lock()
8838                .unwrap_or_else(|poisoned| poisoned.into_inner())
8839                .events
8840                .iter()
8841                .filter(|event| event.sequence > scan_after)
8842                .cloned()
8843                .collect::<Vec<_>>();
8844            for envelope in events {
8845                scan_after = scan_after.max(envelope.sequence);
8846                let NodeEvent::Control { address, event } = envelope.event else {
8847                    continue;
8848                };
8849                if address.session.instance_id != initial_address.session.instance_id {
8850                    continue;
8851                }
8852                match event.event {
8853                    ControlEventKind::Resumed { .. } => return Ok(address),
8854                    ControlEventKind::ResumeDenied { reason } => {
8855                        return Err(failure(
8856                            NodeFailureCode::SessionRecordNotResumable,
8857                            &reason,
8858                        ));
8859                    }
8860                    ControlEventKind::ResumeFailed { message }
8861                    | ControlEventKind::CommandRejected { message } => {
8862                        return Err(failure(
8863                            NodeFailureCode::BackendOperationFailed,
8864                            &message,
8865                        ));
8866                    }
8867                    _ => {}
8868                }
8869            }
8870            if Instant::now() >= deadline {
8871                return Err(failure(
8872                    NodeFailureCode::BackendBusy,
8873                    "managed resume did not settle before the bounded deadline",
8874                ));
8875            }
8876            sleep(Duration::from_millis(5)).await;
8877        }
8878    }
8879
8880    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8881        self.workspaces
8882            .read()
8883            .unwrap_or_else(|poisoned| poisoned.into_inner())
8884            .get(workspace_id)
8885            .cloned()
8886            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8887    }
8888
8889    fn workspace_roots(&self) -> Vec<PathBuf> {
8890        self.workspaces
8891            .read()
8892            .unwrap_or_else(|poisoned| poisoned.into_inner())
8893            .values()
8894            .map(PathBuf::from)
8895            .collect()
8896    }
8897
8898    async fn inspect_workspace(
8899        &self,
8900        workspace_id: WorkspaceId,
8901    ) -> Result<WorkspaceInspection, NodeFailure> {
8902        let _permit = self
8903            .inspection_slots
8904            .clone()
8905            .try_acquire_owned()
8906            .map_err(|_| {
8907                tracing::warn!(
8908                    workspace_id = %workspace_id,
8909                    "workspace inspection rejected: inspection capacity is busy",
8910                );
8911                failure(
8912                    NodeFailureCode::BackendBusy,
8913                    "workspace inspection capacity is busy",
8914                )
8915            })?;
8916        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8917            tracing::warn!(
8918                workspace_id = %workspace_id,
8919                code = ?error.code,
8920                "workspace inspection rejected: workspace is not registered",
8921            );
8922            error
8923        })?;
8924        let tree_root = canonical_root.clone();
8925        let started_at = Instant::now();
8926        let time_budget_ms = workspace_inspection_time_budget_ms();
8927        let entry_cap = workspace_inspection_entry_cap();
8928        let deadline = started_at + Duration::from_millis(time_budget_ms);
8929        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8930            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8931        })
8932        .await
8933        .map_err(|error| {
8934            tracing::warn!(
8935                workspace_id = %workspace_id,
8936                error = %error,
8937                "workspace inspection rejected: walk task failed",
8938            );
8939            failure(
8940                NodeFailureCode::BackendOperationFailed,
8941                &format!("workspace inspection task failed: {error}"),
8942            )
8943        })?;
8944        let (mut git, git_time_budget_exceeded) =
8945            inspect_git_workspace(&canonical_root, deadline).await;
8946        if !git.worktrees.is_empty() {
8947            let registered = self
8948                .workspaces
8949                .read()
8950                .unwrap_or_else(|poisoned| poisoned.into_inner())
8951                .iter()
8952                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8953                .collect::<Vec<_>>();
8954            for worktree in &mut git.worktrees {
8955                worktree.workspace_id = registered
8956                    .iter()
8957                    .find(|(_, root)| {
8958                        worktree_paths_equal(root, windows_path_text(&worktree.path))
8959                    })
8960                    .map(|(workspace_id, _)| workspace_id.clone());
8961            }
8962        }
8963        if git.is_repository {
8964            git.managed_worktree = self
8965                .managed_worktrees
8966                .lock()
8967                .unwrap_or_else(|poisoned| poisoned.into_inner())
8968                .git_scope(&workspace_id, git.branch.as_deref());
8969        }
8970        let truncation = workspace_inspection_truncation(
8971            &walk_budget,
8972            git_time_budget_exceeded,
8973            started_at.elapsed(),
8974        );
8975        if let Some(truncation) = &truncation {
8976            tracing::info!(
8977                workspace_id = %workspace_id,
8978                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8979                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8980                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8981                entries_visited = truncation.entries_visited,
8982                elapsed_ms = truncation.elapsed_ms,
8983                "workspace inspection truncated by its inner budget",
8984            );
8985        }
8986        Ok(WorkspaceInspection {
8987            workspace_id,
8988            entries,
8989            tree_truncated,
8990            git,
8991            truncation,
8992        })
8993    }
8994
8995    async fn browse_host_directories(
8996        &self,
8997        directory: Option<OpaqueHostPath>,
8998        after: Option<OpaqueHostPath>,
8999    ) -> Result<HostDirectoryListing, NodeFailure> {
9000        let permit = self
9001            .inspection_slots
9002            .clone()
9003            .try_acquire_owned()
9004            .map_err(|_| {
9005                failure(
9006                    NodeFailureCode::BackendBusy,
9007                    "host directory browse capacity is busy",
9008                )
9009            })?;
9010        let task = tokio::task::spawn_blocking(move || {
9011            let _permit = permit;
9012            browse_host_directories(directory, after)
9013        });
9014        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
9015            .await
9016            .map_err(|_| {
9017                failure(
9018                    NodeFailureCode::HostDirectoryReadTimedOut,
9019                    "host directory browse exceeded its bounded deadline",
9020                )
9021            })?
9022            .map_err(|_| {
9023                failure(
9024                    NodeFailureCode::HostDirectoryReadFailed,
9025                    "host directory browse task failed",
9026                )
9027            })?
9028            .map_err(host_directory_failure)
9029    }
9030
9031    async fn read_workspace_file(
9032        &self,
9033        workspace_id: WorkspaceId,
9034        path: RepositoryPath,
9035    ) -> Result<WorkspaceFileRead, NodeFailure> {
9036        let canonical_root = self.workspace_root(&workspace_id)?;
9037        let permit = self
9038            .inspection_slots
9039            .clone()
9040            .try_acquire_owned()
9041            .map_err(|_| {
9042                failure(
9043                    NodeFailureCode::BackendBusy,
9044                    "workspace file read capacity is busy",
9045                )
9046            })?;
9047        let path_to_read = path.clone();
9048        let task = tokio::task::spawn_blocking(move || {
9049            let _permit = permit;
9050            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
9051        });
9052        let content = timeout(
9053            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
9054            task,
9055        )
9056        .await
9057        .map_err(|_| {
9058            failure(
9059                NodeFailureCode::RepositoryFileReadTimedOut,
9060                "repository file read exceeded its bounded deadline",
9061            )
9062        })?
9063        .map_err(|_| {
9064            failure(
9065                NodeFailureCode::RepositoryFileReadFailed,
9066                "repository file read task failed",
9067            )
9068        })?
9069        .map_err(workspace_file_failure)?;
9070        let content = match content {
9071            WorkspaceFileBytes::Utf8(text) => {
9072                let revision = Some(workspace_file_revision(text.as_bytes()));
9073                return Ok(WorkspaceFileRead {
9074                    workspace_id,
9075                    path,
9076                    content: WorkspaceFileContent::Utf8 {
9077                        byte_len: u32::try_from(text.len())
9078                            .expect("bounded workspace text length must fit u32"),
9079                        text,
9080                    },
9081                    revision,
9082                });
9083            }
9084            WorkspaceFileBytes::NonUtf8 { byte_length } => {
9085                WorkspaceFileContent::NonUtf8 {
9086                    byte_len: u32::try_from(byte_length)
9087                        .expect("bounded workspace file length must fit u32"),
9088                }
9089            }
9090            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9091                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9092            },
9093        };
9094        Ok(WorkspaceFileRead {
9095            workspace_id,
9096            path,
9097            content,
9098            revision: None,
9099        })
9100    }
9101
9102    async fn write_workspace_file(
9103        &self,
9104        workspace_id: WorkspaceId,
9105        path: RepositoryPath,
9106        expected_revision: WorkspaceFileRevision,
9107        text: String,
9108    ) -> Result<WorkspaceFileRead, NodeFailure> {
9109        let canonical_root = self.workspace_root(&workspace_id)?;
9110        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9111            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9112        })?;
9113        let path_to_write = path.clone();
9114        let expected = expected_revision.as_str().to_owned();
9115        let text_to_write = text.clone();
9116        let task = tokio::task::spawn_blocking(move || {
9117            let _permit = permit;
9118            write_workspace_file_to_disk(
9119                Path::new(&canonical_root),
9120                &path_to_write,
9121                &expected,
9122                &text_to_write,
9123            )
9124        });
9125        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9126            .await
9127            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9128            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9129            .map_err(workspace_file_write_failure)?;
9130        Ok(WorkspaceFileRead {
9131            workspace_id,
9132            path,
9133            content: WorkspaceFileContent::Utf8 {
9134                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9135                text,
9136            },
9137            revision: Some(WorkspaceFileRevision::new(revision)
9138                .expect("workspace writer returns a valid SHA-256 revision")),
9139        })
9140    }
9141
9142    async fn create_workspace_file(
9143        &self,
9144        workspace_id: WorkspaceId,
9145        path: RepositoryPath,
9146    ) -> Result<WorkspaceFileRead, NodeFailure> {
9147        let canonical_root = self.workspace_root(&workspace_id)?;
9148        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9149            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9150        })?;
9151        let path_to_create = path.clone();
9152        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9153        let task_commit_state = Arc::clone(&commit_state);
9154        let task = tokio::task::spawn_blocking(move || {
9155            let _permit = permit;
9156            create_workspace_file_on_disk(
9157                Path::new(&canonical_root),
9158                &path_to_create,
9159                &task_commit_state,
9160            )
9161        });
9162        let revision = settle_workspace_entry_create(
9163            task,
9164            commit_state,
9165            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9166            "repository file create exceeded its bounded deadline",
9167            "repository file create task failed",
9168        )
9169        .await?;
9170        Ok(WorkspaceFileRead {
9171            workspace_id,
9172            path,
9173            content: WorkspaceFileContent::Utf8 {
9174                text: String::new(),
9175                byte_len: 0,
9176            },
9177            revision: Some(WorkspaceFileRevision::new(revision)
9178                .expect("workspace creator returns a valid SHA-256 revision")),
9179        })
9180    }
9181
9182    async fn create_workspace_directory(
9183        &self,
9184        workspace_id: WorkspaceId,
9185        path: RepositoryPath,
9186    ) -> Result<WorkspaceEntry, NodeFailure> {
9187        let canonical_root = self.workspace_root(&workspace_id)?;
9188        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9189            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9190        })?;
9191        let path_to_create = path.clone();
9192        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9193        let task_commit_state = Arc::clone(&commit_state);
9194        let task = tokio::task::spawn_blocking(move || {
9195            let _permit = permit;
9196            create_workspace_directory_on_disk(
9197                Path::new(&canonical_root),
9198                &path_to_create,
9199                &task_commit_state,
9200            )
9201        });
9202        settle_workspace_entry_create(
9203            task,
9204            commit_state,
9205            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9206            "repository directory create exceeded its bounded deadline",
9207            "repository directory create task failed",
9208        )
9209        .await?;
9210        Ok(WorkspaceEntry {
9211            relative_path: path,
9212            kind: WorkspaceEntryKind::Directory,
9213        })
9214    }
9215
9216    async fn read_git_history(
9217        &self,
9218        workspace_id: WorkspaceId,
9219        path: Option<RepositoryPath>,
9220        before: Option<GitObjectId>,
9221        limit: u16,
9222    ) -> Result<GitHistoryPage, NodeFailure> {
9223        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9224            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9225        })?;
9226        let root = self.workspace_root(&workspace_id)?;
9227        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9228    }
9229
9230    async fn read_git_diff(
9231        &self,
9232        workspace_id: WorkspaceId,
9233        request: GitDiffRequest,
9234    ) -> Result<GitDiff, NodeFailure> {
9235        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9236            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9237        })?;
9238        let root = self.workspace_root(&workspace_id)?;
9239        read_git_diff_bounded(&root, request).await
9240    }
9241
9242    fn native_session_route_root(
9243        &self,
9244        route: &NativeSessionCatalogRoute,
9245    ) -> Result<Option<String>, NodeFailure> {
9246        route
9247            .validate()
9248            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9249        match route.scope {
9250            gate4agent_types::NativeSessionCatalogScope::Workspace => route
9251                .workspace_id
9252                .as_ref()
9253                .map(|workspace_id| self.workspace_root(workspace_id))
9254                .transpose(),
9255            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9256        }
9257    }
9258
9259    fn project_native_session_entries(
9260        &self,
9261        route: &NativeSessionCatalogRoute,
9262        entries: Vec<ScopedNativeSessionCatalogEntry>,
9263    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9264        let current_root = route
9265            .workspace_id
9266            .as_ref()
9267            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9268        let records = self
9269            .session_records
9270            .lock()
9271            .unwrap_or_else(|poisoned| poisoned.into_inner());
9272        let mut seen_records = std::collections::HashSet::new();
9273        entries
9274            .into_iter()
9275            .map(|entry| {
9276                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9277                    let matches = records
9278                        .records
9279                        .values()
9280                        .filter(|record| {
9281                            record.provider == route.provider
9282                                && record.workspace_id == *workspace_id
9283                                && current_root.as_deref().is_some_and(|root| {
9284                                    platform::roots_equal(
9285                                        root,
9286                                        windows_path_text(&record.canonical_root),
9287                                    )
9288                                })
9289                                && record.provider_session.as_ref().is_some_and(|identity| {
9290                                    session_registry::same_provider_session(
9291                                        &record.provider,
9292                                        identity,
9293                                        &route.provider,
9294                                        &entry.provider_session,
9295                                    )
9296                                })
9297                        })
9298                        .map(|record| record.record_id.clone())
9299                        .collect::<Vec<_>>();
9300                    (matches.len() == 1
9301                        && seen_records.insert(matches[0].clone()))
9302                        .then(|| matches[0].clone())
9303                });
9304                let metadata = entry.metadata;
9305                crate::protocol::NativeSessionCatalogEntry {
9306                    selection_id: metadata.selection_id,
9307                    title: metadata.title,
9308                    modified_at_unix_ms: metadata.modified_at_unix_ms,
9309                    model: metadata.model,
9310                    message_count: metadata.message_count,
9311                    completed_turn_count: metadata.completed_turn_count,
9312                    external_group: entry.external_group,
9313                    record_id,
9314                }
9315            })
9316            .collect()
9317    }
9318
9319    async fn catalog_native_sessions(
9320        &self,
9321        route: NativeSessionCatalogRoute,
9322        limit: u16,
9323    ) -> Result<(
9324        Vec<crate::protocol::NativeSessionCatalogEntry>,
9325        crate::protocol::NativeSessionCatalogSummary,
9326    ), NodeFailure> {
9327        let canonical_root = self.native_session_route_root(&route)?;
9328        let registered_workspace_roots = self.workspace_roots();
9329        let provider = route.provider.clone();
9330        let scope = route.scope;
9331        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9332            failure(
9333                NodeFailureCode::InvalidRequest,
9334                "native session catalog is not configured",
9335            )
9336        })?;
9337        let _permit = Arc::clone(&self.inspection_slots)
9338            .try_acquire_owned()
9339            .map_err(|_| {
9340                failure(
9341                    NodeFailureCode::BackendBusy,
9342                    "native session catalog capacity is busy",
9343                )
9344            })?;
9345        let page = run_native_session_catalog_operation(
9346            catalog,
9347            "native session catalog",
9348            move |catalog| {
9349                catalog.catalog_initial_for_scope(
9350                    &provider,
9351                    scope,
9352                    canonical_root.as_deref().map(Path::new),
9353                    &registered_workspace_roots,
9354                    limit,
9355                )
9356            },
9357        )
9358        .await?;
9359        let summary = crate::protocol::NativeSessionCatalogSummary {
9360            catalog_revision: page.revision,
9361            recent_cutoff_unix_ms: page.cutoff_unix_ms,
9362            recent_total_count: page.recent_total_count,
9363            older_total_count: page.older_total_count,
9364            recent_next_after_selection_id: page.next_after_selection_id,
9365            recent_has_more: page.remaining_count > 0,
9366        };
9367        Ok((self.project_native_session_entries(&route, page.entries), summary))
9368    }
9369
9370    async fn page_native_sessions(
9371        &self,
9372        route: NativeSessionCatalogRoute,
9373        window: crate::protocol::NativeSessionCatalogWindow,
9374        catalog_revision: u64,
9375        recent_cutoff_unix_ms: u64,
9376        after_selection_id: Option<String>,
9377        limit: u16,
9378    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9379        let canonical_root = self.native_session_route_root(&route)?;
9380        let registered_workspace_roots = self.workspace_roots();
9381        let provider = route.provider.clone();
9382        let scope = route.scope;
9383        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9384            failure(
9385                NodeFailureCode::InvalidRequest,
9386                "native session catalog is not configured",
9387            )
9388        })?;
9389        let _permit = Arc::clone(&self.inspection_slots)
9390            .try_acquire_owned()
9391            .map_err(|_| {
9392                failure(
9393                    NodeFailureCode::BackendBusy,
9394                    "native session catalog capacity is busy",
9395                )
9396            })?;
9397        let page = run_native_session_catalog_operation(
9398            catalog,
9399            "native session catalog page",
9400            move |catalog| {
9401                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9402                    &provider,
9403                    scope,
9404                    canonical_root.as_deref().map(Path::new),
9405                    &registered_workspace_roots,
9406                    window,
9407                    catalog_revision,
9408                    recent_cutoff_unix_ms,
9409                    after_selection_id.as_deref(),
9410                    limit,
9411                ))
9412            },
9413        )
9414        .await?;
9415        let page = page.map_err(|error| match error {
9416            NativeSessionCatalogError::StaleCatalog => failure(
9417                NodeFailureCode::StaleNativeSessionCatalog,
9418                "native session catalog revision or cursor is stale",
9419            ),
9420            _ => failure(
9421                NodeFailureCode::BackendOperationFailed,
9422                "native session catalog page failed",
9423            ),
9424        })?;
9425        let entries = self.project_native_session_entries(&route, page.entries);
9426        Ok(crate::protocol::NativeSessionCatalogPage {
9427            window: page.window,
9428            revision: page.revision,
9429            entries,
9430            next_after_selection_id: page.next_after_selection_id,
9431            remaining_count: page.remaining_count,
9432            has_more: page.remaining_count > 0,
9433        })
9434    }
9435
9436    async fn preview_native_session(
9437        &self,
9438        selection: NativeSessionSelection,
9439        message_limit: u16,
9440    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9441        selection
9442            .validate()
9443            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9444        let canonical_root = self.native_session_route_root(&selection.route)?;
9445        let registered_workspace_roots = self.workspace_roots();
9446        let provider = selection.route.provider.clone();
9447        let scope = selection.route.scope;
9448        let catalog_revision = selection.catalog_revision;
9449        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9450        let selection_id = selection.selection_id.clone();
9451        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9452            failure(
9453                NodeFailureCode::InvalidRequest,
9454                "native session preview is not configured",
9455            )
9456        })?;
9457        let _permit = Arc::clone(&self.inspection_slots)
9458            .try_acquire_owned()
9459            .map_err(|_| {
9460                failure(
9461                    NodeFailureCode::BackendBusy,
9462                    "native session preview capacity is busy",
9463                )
9464            })?;
9465        let preview = run_native_session_catalog_operation(
9466            catalog,
9467            "native session preview",
9468            move |catalog| {
9469                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9470                    &provider,
9471                    scope,
9472                    canonical_root.as_deref().map(Path::new),
9473                    &registered_workspace_roots,
9474                    catalog_revision,
9475                    recent_cutoff_unix_ms,
9476                    &selection_id,
9477                    message_limit,
9478                ))
9479            },
9480        )
9481        .await?;
9482        preview
9483            .map(Into::into)
9484            .map_err(native_session_preview_failure)
9485    }
9486
9487    async fn index_native_session(
9488        &self,
9489        selection: NativeSessionSelection,
9490        display_name: String,
9491    ) -> Result<ManagedSessionRecord, NodeFailure> {
9492        selection
9493            .validate()
9494            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9495        if selection.route.scope
9496            != gate4agent_types::NativeSessionCatalogScope::Workspace
9497        {
9498            return Err(failure(
9499                NodeFailureCode::WorkspaceRegistrationRequired,
9500                "register the external project as a workspace before indexing or resuming it",
9501            ));
9502        }
9503        let workspace_id = selection
9504            .route
9505            .workspace_id
9506            .clone()
9507            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9508        let canonical_root = self
9509            .native_session_route_root(&selection.route)?
9510            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9511        let registered_workspace_roots = self.workspace_roots();
9512        let provider = selection.route.provider.clone();
9513        let catalog_revision = selection.catalog_revision;
9514        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9515        let selection_id = selection.selection_id.clone();
9516        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9517            failure(
9518                NodeFailureCode::InvalidRequest,
9519                "native session index is not configured",
9520            )
9521        })?;
9522        let _permit = Arc::clone(&self.inspection_slots)
9523            .try_acquire_owned()
9524            .map_err(|_| {
9525                failure(
9526                    NodeFailureCode::BackendBusy,
9527                    "native session index capacity is busy",
9528                )
9529            })?;
9530        let resolved = run_native_session_catalog_operation(
9531            catalog,
9532            "native session index resolution",
9533            move |catalog| {
9534                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9535                    &provider,
9536                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9537                    Some(Path::new(&canonical_root)),
9538                    &registered_workspace_roots,
9539                    catalog_revision,
9540                    recent_cutoff_unix_ms,
9541                    &selection_id,
9542                ))
9543            },
9544        )
9545        .await?
9546        .map_err(native_session_preview_failure)?;
9547        self.index_provider_session_with_policy(
9548            workspace_id,
9549            selection.route.provider,
9550            resolved.identity,
9551            display_name,
9552            true,
9553        )
9554    }
9555
9556    async fn preview_native_session_by_session_id(
9557        &self,
9558        workspace_id: WorkspaceId,
9559        provider: AgentId,
9560        session_id: String,
9561        message_limit: u16,
9562    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9563        let canonical_root = self.workspace_root(&workspace_id)?;
9564        let registered_workspace_roots = self.workspace_roots();
9565        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9566            failure(
9567                NodeFailureCode::InvalidRequest,
9568                "native session preview is not configured",
9569            )
9570        })?;
9571        let _permit = Arc::clone(&self.inspection_slots)
9572            .try_acquire_owned()
9573            .map_err(|_| {
9574                failure(
9575                    NodeFailureCode::BackendBusy,
9576                    "native session preview capacity is busy",
9577                )
9578            })?;
9579        run_native_session_catalog_operation(
9580            catalog,
9581            "native session preview",
9582            move |catalog| {
9583                catalog.preview_session_id_for_workspace(
9584                    &provider,
9585                    Path::new(&canonical_root),
9586                    &registered_workspace_roots,
9587                    &session_id,
9588                    message_limit,
9589                )
9590            },
9591        )
9592        .await
9593    }
9594
9595    async fn register_workspace(
9596        &self,
9597        workspace_id: WorkspaceId,
9598        root: String,
9599    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9600        validate_workspace_request_root(&root)?;
9601        let canonicalize_workspace_id = workspace_id.clone();
9602        let workspace = tokio::task::spawn_blocking(move || {
9603            WorkspaceConfig::new(canonicalize_workspace_id, root)
9604        })
9605        .await
9606        .map_err(|error| {
9607            failure(
9608                NodeFailureCode::BackendOperationFailed,
9609                &format!("workspace canonicalization task failed: {error}"),
9610            )
9611        })?
9612        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9613        let transaction = self
9614            .state_transaction
9615            .lock()
9616            .unwrap_or_else(|poisoned| poisoned.into_inner());
9617        let mut workspaces = self
9618            .workspaces
9619            .write()
9620            .unwrap_or_else(|poisoned| poisoned.into_inner());
9621        if workspaces.contains_key(&workspace_id) {
9622            return Err(failure(
9623                NodeFailureCode::DuplicateWorkspaceId,
9624                "workspace ID is already registered",
9625            ));
9626        }
9627        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9628            platform::roots_equal(existing_root, workspace.canonical_root())
9629        }) {
9630            return Err(failure(
9631                NodeFailureCode::DuplicateWorkspaceRoot,
9632                &format!("workspace root is already registered as '{existing_id}'"),
9633            ));
9634        }
9635        {
9636            let records = self
9637                .session_records
9638                .lock()
9639                .unwrap_or_else(|poisoned| poisoned.into_inner());
9640            if let Some(record) = records.records.values().find(|record| {
9641                record.workspace_id == workspace_id
9642                    && !record
9643                        .canonical_root
9644                        .as_utf8()
9645                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9646            }) {
9647                return Err(failure(
9648                    NodeFailureCode::SessionRecordConflict,
9649                    &format!(
9650                        "workspace ID is retained by session record '{}' at another root",
9651                        record.record_id,
9652                    ),
9653                ));
9654            }
9655        }
9656        let snapshot = WorkspaceSnapshot {
9657            workspace_id: workspace_id.clone(),
9658            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9659            sessions: Vec::new(),
9660            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9661            managed_worktree_profiles: Some(WorktreeProfileInventory {
9662                profiles: Vec::new(),
9663            }),
9664        };
9665        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9666        drop(workspaces);
9667        let previous_records = {
9668            let mut records = self
9669                .session_records
9670                .lock()
9671                .unwrap_or_else(|poisoned| poisoned.into_inner());
9672            let previous = records.records.clone();
9673            for record in records.records.values_mut().filter(|record| {
9674                record.workspace_id == workspace_id
9675                    && record
9676                        .canonical_root
9677                        .as_utf8()
9678                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9679            }) {
9680                if record.active_session.is_none() {
9681                    record.state = detached_record_state(record);
9682                }
9683                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9684                    record.last_error = None;
9685                }
9686                record.updated_at_unix_ms = unix_time_ms();
9687            }
9688            previous
9689        };
9690        if let Err(error) = self.persist_state_locked() {
9691            self.workspaces
9692                .write()
9693                .unwrap_or_else(|poisoned| poisoned.into_inner())
9694                .remove(&workspace_id);
9695            self.session_records
9696                .lock()
9697                .unwrap_or_else(|poisoned| poisoned.into_inner())
9698                .records = previous_records;
9699            return Err(persistence_failure(error));
9700        }
9701        let restored = self
9702            .session_records
9703            .lock()
9704            .unwrap_or_else(|poisoned| poisoned.into_inner())
9705            .records
9706            .values()
9707            .filter(|record| record.workspace_id == workspace_id)
9708            .cloned()
9709            .collect::<Vec<_>>();
9710        drop(transaction);
9711        self.publish(NodeEvent::WorkspaceAdded {
9712            workspace: snapshot.clone(),
9713        });
9714        for record in restored {
9715            self.publish_record(record);
9716        }
9717        Ok(snapshot)
9718    }
9719
9720    async fn create_standalone_workspace(
9721        &self,
9722        workspace_id: WorkspaceId,
9723        root: String,
9724        initial_branch: Option<String>,
9725    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9726        validate_workspace_request_root(&root)?;
9727        validate_git_revision("initial branch", initial_branch.as_deref())?;
9728        let preflight_workspace_id = workspace_id.clone();
9729        let preflight_root = root.clone();
9730        let canonical_candidate = tokio::task::spawn_blocking(move || {
9731            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9732        })
9733        .await
9734        .map_err(|_| failure(
9735            NodeFailureCode::BackendOperationFailed,
9736            "standalone workspace root preflight task failed",
9737        ))?
9738        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9739        {
9740            let workspaces = self
9741                .workspaces
9742                .read()
9743                .unwrap_or_else(|poisoned| poisoned.into_inner());
9744            if workspaces.contains_key(&workspace_id) {
9745                return Err(failure(
9746                    NodeFailureCode::DuplicateWorkspaceId,
9747                    "workspace ID is already registered",
9748                ));
9749            }
9750            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9751                platform::roots_equal(existing_root, &canonical_candidate)
9752            }) {
9753                return Err(failure(
9754                    NodeFailureCode::DuplicateWorkspaceRoot,
9755                    &format!("workspace root is already registered as '{existing_id}'"),
9756                ));
9757            }
9758        }
9759        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9760            .await
9761            .map_err(standalone_workspace_failure)?;
9762        prepared
9763            .verify_for_registration()
9764            .await
9765            .map_err(standalone_workspace_failure)?;
9766        match self
9767            .register_workspace(workspace_id, prepared.root().to_owned())
9768            .await
9769        {
9770            Ok(workspace) => Ok(workspace),
9771            Err(registration_error) => {
9772                if prepared.compensate_registration_failure().await {
9773                    Err(registration_error)
9774                } else {
9775                    Err(failure(
9776                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9777                        "standalone workspace initialization succeeded but registration failed; recovery is required",
9778                    ))
9779                }
9780            }
9781        }
9782    }
9783
9784    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9785        let bindings = self
9786            .session_bindings
9787            .lock()
9788            .unwrap_or_else(|poisoned| poisoned.into_inner());
9789        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9790            return Err(failure(
9791                NodeFailureCode::WorkspaceBusy,
9792                "workspace retains a bound session; remove every session before unregistering it",
9793            ));
9794        }
9795        drop(bindings);
9796        let transaction = self
9797            .state_transaction
9798            .lock()
9799            .unwrap_or_else(|poisoned| poisoned.into_inner());
9800        let mut workspaces = self
9801            .workspaces
9802            .write()
9803            .unwrap_or_else(|poisoned| poisoned.into_inner());
9804        if !workspaces.contains_key(workspace_id) {
9805            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9806        }
9807        if workspaces.len() == 1 {
9808            return Err(failure(
9809                NodeFailureCode::LastWorkspace,
9810                "node must retain at least one workspace",
9811            ));
9812        }
9813        let removed_root = workspaces
9814            .remove(workspace_id)
9815            .expect("workspace presence was validated");
9816        drop(workspaces);
9817        let previous_records = {
9818            let mut records = self
9819                .session_records
9820                .lock()
9821                .unwrap_or_else(|poisoned| poisoned.into_inner());
9822            let previous = records.records.clone();
9823            for record in records
9824                .records
9825                .values_mut()
9826                .filter(|record| &record.workspace_id == workspace_id)
9827            {
9828                record.active_session = None;
9829                record.state = ManagedSessionState::Unavailable;
9830                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9831                record.updated_at_unix_ms = unix_time_ms();
9832            }
9833            previous
9834        };
9835        if let Err(error) = self.persist_state_locked() {
9836            self.workspaces
9837                .write()
9838                .unwrap_or_else(|poisoned| poisoned.into_inner())
9839                .insert(workspace_id.clone(), removed_root);
9840            self.session_records
9841                .lock()
9842                .unwrap_or_else(|poisoned| poisoned.into_inner())
9843                .records = previous_records;
9844            return Err(persistence_failure(error));
9845        }
9846        let unavailable = self
9847            .session_records
9848            .lock()
9849            .unwrap_or_else(|poisoned| poisoned.into_inner())
9850            .records
9851            .values()
9852            .filter(|record| &record.workspace_id == workspace_id)
9853            .cloned()
9854            .collect::<Vec<_>>();
9855        drop(transaction);
9856        self.publish(NodeEvent::WorkspaceRemoved {
9857            workspace_id: workspace_id.clone(),
9858        });
9859        for record in unavailable {
9860            self.publish_record(record);
9861        }
9862        Ok(())
9863    }
9864
9865    async fn create_worktree(
9866        &self,
9867        source_workspace_id: WorkspaceId,
9868        workspace_id: WorkspaceId,
9869        target_root: String,
9870        branch: String,
9871        base: Option<String>,
9872    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9873        self.require_worktree_service(&source_workspace_id)?;
9874        validate_workspace_request_root(&target_root)?;
9875        validate_git_revision("worktree base", base.as_deref())?;
9876        let source_root = self.workspace_root(&source_workspace_id)?;
9877        {
9878            let workspaces = self
9879                .workspaces
9880                .read()
9881                .unwrap_or_else(|poisoned| poisoned.into_inner());
9882            if workspaces.contains_key(&workspace_id) {
9883                return Err(failure(
9884                    NodeFailureCode::DuplicateWorkspaceId,
9885                    "workspace ID is already registered",
9886                ));
9887            }
9888            if let Some((existing_id, _)) = workspaces
9889                .iter()
9890                .find(|(_, root)| worktree_paths_equal(root, &target_root))
9891            {
9892                return Err(failure(
9893                    NodeFailureCode::DuplicateWorkspaceRoot,
9894                    &format!("workspace root is already registered as '{existing_id}'"),
9895                ));
9896            }
9897        }
9898        let mut worktree = create_git_worktree(
9899            &source_root,
9900            &target_root,
9901            &branch,
9902            base.as_deref(),
9903        )
9904        .await
9905        .map_err(git_worktree_failure)?;
9906        let workspace = match self
9907            .register_workspace(workspace_id, worktree.path.clone())
9908            .await
9909        {
9910            Ok(workspace) => workspace,
9911            Err(registration_error) => {
9912                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9913                    return Err(failure(
9914                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
9915                        "worktree registration failed and compensation requires recovery",
9916                    ));
9917                }
9918                return Err(failure(
9919                    registration_error.code,
9920                    &registration_error.message,
9921                ));
9922            }
9923        };
9924        worktree.workspace_id = Some(workspace.workspace_id.clone());
9925        Ok((protocol_worktree(worktree), workspace))
9926    }
9927
9928    async fn remove_worktree(
9929        &self,
9930        source_workspace_id: WorkspaceId,
9931        target_root: String,
9932    ) -> Result<Option<WorkspaceId>, NodeFailure> {
9933        self.require_worktree_service(&source_workspace_id)?;
9934        validate_workspace_request_root(&target_root)?;
9935        let source_root = self.workspace_root(&source_workspace_id)?;
9936        let target_root = normalize_worktree_removal_target(&target_root)
9937            .map_err(git_worktree_failure)?;
9938        let target = list_git_worktrees(&source_root)
9939            .await
9940            .map_err(git_worktree_failure)?
9941            .into_iter()
9942            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9943            .ok_or_else(|| {
9944                failure(
9945                    NodeFailureCode::WorktreeProtected,
9946                    "refusing to remove a path that is not in Git's worktree listing",
9947                )
9948            })?;
9949        let registered_workspace_id = {
9950            let workspaces = self
9951                .workspaces
9952                .read()
9953                .unwrap_or_else(|poisoned| poisoned.into_inner());
9954            let matched = workspaces
9955                .iter()
9956                .find(|(_, root)| worktree_paths_equal(root, &target.path))
9957                .map(|(workspace_id, _)| workspace_id.clone());
9958            if matched.is_some() && workspaces.len() == 1 {
9959                return Err(failure(
9960                    NodeFailureCode::LastWorkspace,
9961                    "node must retain at least one workspace",
9962                ));
9963            }
9964            matched
9965        };
9966        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9967            let bindings = self
9968                .session_bindings
9969                .lock()
9970                .unwrap_or_else(|poisoned| poisoned.into_inner());
9971            if bindings
9972                .values()
9973                .any(|binding| &binding.workspace_id == workspace_id)
9974            {
9975                return Err(failure(
9976                    NodeFailureCode::WorkspaceBusy,
9977                    "worktree workspace retains a bound session; remove every session first",
9978                ));
9979            }
9980        }
9981        remove_git_worktree(&source_root, &target.path)
9982            .await
9983            .map_err(git_worktree_failure)?;
9984        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9985            self.unregister_workspace(workspace_id)
9986                .map_err(|error| failure(error.code, &error.message))?;
9987        }
9988        Ok(registered_workspace_id)
9989    }
9990
9991    async fn cleanup_managed_worktree(
9992        &self,
9993        lease_id: &ManagedWorktreeLeaseId,
9994        explicit: bool,
9995    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9996        let lease = self.managed_worktrees.lock()
9997            .unwrap_or_else(|poisoned| poisoned.into_inner())
9998            .get(lease_id)
9999            .cloned()
10000            .ok_or_else(|| failure(
10001                NodeFailureCode::UnknownManagedWorktreeLease,
10002                "managed worktree lease does not exist",
10003            ))?;
10004        if lease.state == ManagedWorktreeLeaseState::Removed {
10005            return Ok(lease.snapshot());
10006        }
10007        if lease.has_holders() {
10008            return Err(failure(
10009                NodeFailureCode::ManagedWorktreeBusy,
10010                "managed worktree lease still has a session or durable-record holder",
10011            ));
10012        }
10013        if self
10014            .materializations
10015            .lock()
10016            .unwrap_or_else(|poisoned| poisoned.into_inner())
10017            .values()
10018            .any(|record| record.managed_lease_id() == Some(lease_id))
10019        {
10020            return Err(failure(
10021                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10022                "managed worktree cleanup is blocked by session environment ownership",
10023            ));
10024        }
10025        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
10026            let snapshot = {
10027                let mut registry = self.managed_worktrees.lock()
10028                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10029                let current = registry.get_mut(lease_id)
10030                    .expect("managed worktree lease remains present");
10031                current.state = ManagedWorktreeLeaseState::Retained;
10032                current.cleanup_failure = None;
10033                current.updated_at_unix_ms = unix_time_ms();
10034                current.snapshot()
10035            };
10036            if self.persist_state().is_err() {
10037                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10038            }
10039            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
10040            return Ok(snapshot);
10041        }
10042        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
10043        if profile.revision() != &lease.profile_revision {
10044            self.mark_managed_recovery_required(
10045                lease_id,
10046                ManagedWorktreeCleanupFailure::OwnershipConflict,
10047            );
10048            return Err(failure(
10049                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10050                "managed worktree profile revision changed",
10051            ));
10052        }
10053        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
10054            self.mark_managed_recovery_required(
10055                lease_id,
10056                ManagedWorktreeCleanupFailure::OwnershipConflict,
10057            );
10058            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
10059        })?;
10060        let source_root = self.workspace_root(&lease.source_workspace_id)?;
10061        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
10062            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
10063            managed_git_worktree_failure(error)
10064        })?;
10065        let target = listed.iter()
10066            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
10067        if let Some(target) = target {
10068            if target.is_main || target.is_bare || !exact_owned_worktree(
10069                &lease,
10070                &target.path,
10071                target.branch.as_deref(),
10072                &target.head,
10073            ) {
10074                self.mark_managed_recovery_required(
10075                    lease_id,
10076                    ManagedWorktreeCleanupFailure::OwnershipConflict,
10077                );
10078                return Err(failure(
10079                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
10080                    "Git worktree identity no longer matches the managed lease",
10081                ));
10082            }
10083            if target.prunable {
10084                self.mark_managed_cleanup_blocked(
10085                    lease_id,
10086                    ManagedWorktreeCleanupFailure::Prunable,
10087                );
10088                return Err(failure(
10089                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
10090                    "managed worktree is prunable and requires recovery",
10091                ));
10092            }
10093            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10094                let failure_kind = match error.kind {
10095                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10096                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10097                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10098                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10099                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10100                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10101                };
10102                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10103                    self.mark_managed_recovery_required(lease_id, failure_kind);
10104                } else {
10105                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10106                }
10107                return Err(managed_git_worktree_failure(error));
10108            }
10109        } else {
10110            match std::fs::symlink_metadata(&lease.target_root) {
10111                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10112                Ok(_) => {
10113                    self.mark_managed_recovery_required(
10114                        lease_id,
10115                        ManagedWorktreeCleanupFailure::OwnershipConflict,
10116                    );
10117                    return Err(failure(
10118                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
10119                        "managed target exists but is not owned by Git",
10120                    ));
10121                }
10122                Err(_) => {
10123                    self.mark_managed_cleanup_blocked(
10124                        lease_id,
10125                        ManagedWorktreeCleanupFailure::Backend,
10126                    );
10127                    return Err(failure(
10128                        NodeFailureCode::BackendOperationFailed,
10129                        "managed target identity could not be inspected",
10130                    ));
10131                }
10132            }
10133        }
10134        let registered = self.workspaces.read()
10135            .unwrap_or_else(|poisoned| poisoned.into_inner())
10136            .contains_key(&lease.workspace_id);
10137        if registered {
10138            self.unregister_workspace(&lease.workspace_id)?;
10139        }
10140        let removed = {
10141            self.managed_worktrees.lock()
10142                .unwrap_or_else(|poisoned| poisoned.into_inner())
10143                .tombstone(lease_id, unix_time_ms())
10144                .expect("managed worktree lease remains present during cleanup")
10145        };
10146        self.persist_state().map_err(persistence_failure)?;
10147        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10148        Ok(removed.snapshot())
10149    }
10150
10151    fn mark_managed_cleanup_blocked(
10152        &self,
10153        lease_id: &ManagedWorktreeLeaseId,
10154        failure_kind: ManagedWorktreeCleanupFailure,
10155    ) {
10156        let snapshot = {
10157            let mut registry = self.managed_worktrees.lock()
10158                .unwrap_or_else(|poisoned| poisoned.into_inner());
10159            let Some(lease) = registry.get_mut(lease_id) else { return };
10160            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10161            lease.cleanup_failure = Some(failure_kind);
10162            lease.updated_at_unix_ms = unix_time_ms();
10163            lease.snapshot()
10164        };
10165        let _ = self.persist_state();
10166        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10167    }
10168
10169    fn require_worktree_service(
10170        &self,
10171        source_workspace_id: &WorkspaceId,
10172    ) -> Result<(), NodeFailure> {
10173        if self.managed_worktrees.lock()
10174            .unwrap_or_else(|poisoned| poisoned.into_inner())
10175            .lease_for_workspace(source_workspace_id)
10176            .is_some()
10177        {
10178            return Err(failure(
10179                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10180                "managed worktree targets are internal-only workspaces",
10181            ));
10182        }
10183        match self
10184            .worktree_service_modes
10185            .get(source_workspace_id)
10186            .copied()
10187            .unwrap_or(WorktreeServiceMode::Manual)
10188        {
10189            WorktreeServiceMode::Manual => Ok(()),
10190            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10191                NodeFailureCode::UnsupportedSpawnCapability,
10192                "Git worktree service mode is unavailable in this node build",
10193            )),
10194        }
10195    }
10196
10197    fn managed_reservation_conflict(
10198        &self,
10199        workspace_id: Option<&WorkspaceId>,
10200        root: Option<&str>,
10201    ) -> bool {
10202        self.managed_worktrees.lock()
10203            .unwrap_or_else(|poisoned| poisoned.into_inner())
10204            .active_records()
10205            .any(|lease| {
10206                workspace_id.is_some_and(|workspace_id| {
10207                    workspace_id == &lease.source_workspace_id
10208                        || workspace_id == &lease.workspace_id
10209                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10210            })
10211    }
10212
10213    fn reject_managed_reservation(
10214        &self,
10215        workspace_id: Option<&WorkspaceId>,
10216        root: Option<&str>,
10217    ) -> Result<(), NodeFailure> {
10218        if self.managed_reservation_conflict(workspace_id, root) {
10219            Err(failure(
10220                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10221                "managed worktree ownership may only be mutated through its lease cleanup",
10222            ))
10223        } else {
10224            Ok(())
10225        }
10226    }
10227
10228    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10229        self.validate_address(address)?;
10230        self.workspace_root(&address.workspace_id)
10231    }
10232
10233    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
10234    /// `require_policy`'s own doc on those two requirements), so they keep
10235    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
10236    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
10237    /// `false` on the `require_policy` call below for them because a PTY
10238    /// resume never needs those facts, not because this method cannot look
10239    /// them up.
10240    ///
10241    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
10242    /// the SAME `binding.runtime_policy` every other requirement reads here
10243    /// (one computed policy, read by every gate). That policy is no longer
10244    /// PTY-terminal-verification-shaped for an ACP session --
10245    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
10246    /// `provider_runtime::policy_for_transport` -- so consulting it for
10247    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
10248    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
10249    /// even though a prompt is an ACP session's only interface). Pipe keeps
10250    /// reading the same PTY-probe-derived value it always has --
10251    /// `policy_for_transport` passes Pipe through unchanged this pass (see
10252    /// its own doc for why).
10253    ///
10254    /// What this method still adds on top, for ACP/Pipe only, is a
10255    /// turn-in-flight PRECONDITION ahead of that policy check: a session
10256    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
10257    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
10258    /// read off the same live `SessionSnapshot` state `require_acp_session`
10259    /// already reads for the ACP control verbs -- `TurnStarted` observed
10260    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
10261    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
10262    /// its fate in vendor-specific behaviour this node cannot observe (one
10263    /// agent may queue it, another drop it, another interleave it), so this
10264    /// refuses it outright rather than gambling on that -- a rule with no
10265    /// PTY analogue, which is why PTY does not take this extra precondition.
10266    fn require_session_runtime_policy(
10267        &self,
10268        address: &SessionAddress,
10269        requirement: ProviderRuntimeRequirement,
10270    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10271        let runtime_policy = {
10272            let bindings = self
10273                .session_bindings
10274                .lock()
10275                .unwrap_or_else(|poisoned| poisoned.into_inner());
10276            let binding = bindings
10277                .get(&address.session.instance_id)
10278                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10279            if binding.workspace_id != address.workspace_id {
10280                return Err(failure(
10281                    NodeFailureCode::SessionWorkspaceMismatch,
10282                    "session belongs to another workspace",
10283                ));
10284            }
10285            if binding.generation != address.session.generation {
10286                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10287            }
10288            binding.runtime_policy
10289        };
10290        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10291            let snapshot = self.internal_session_snapshot(address)?;
10292            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10293                && snapshot.provider.lead_activity != ProviderActivity::Idle
10294            {
10295                return Err(failure(
10296                    NodeFailureCode::TurnInFlight,
10297                    "session already has a turn in flight; wait for it to complete before sending another prompt",
10298                ));
10299            }
10300        }
10301        require_policy(runtime_policy, false, false, requirement)
10302            .map(|()| runtime_policy)
10303            .map_err(|_| failure(
10304                NodeFailureCode::UnsupportedCapability,
10305                "session runtime policy does not admit this semantic operation",
10306            ))
10307    }
10308
10309    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10310        let mut bindings = self
10311            .session_bindings
10312            .lock()
10313            .unwrap_or_else(|poisoned| poisoned.into_inner());
10314        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10315            binding.workspace_id == address.workspace_id
10316                && binding.generation == address.session.generation
10317        }) {
10318            let removed = bindings.remove(&address.session.instance_id);
10319            self.clear_terminal_frame_watermark(address);
10320            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10321            return removed;
10322        }
10323        None
10324    }
10325
10326    fn cleanup_session_owned_materialization(
10327        &self,
10328        address: &SessionAddress,
10329        binding: &SessionBinding,
10330    ) -> Result<(), NodeFailure> {
10331        let Some(id) = binding.materialization_id.as_ref() else {
10332            return Ok(());
10333        };
10334        let owner = self
10335            .materializations
10336            .lock()
10337            .unwrap_or_else(|poisoned| poisoned.into_inner())
10338            .get(id)
10339            .map(|record| record.owner().clone());
10340        match owner {
10341            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10342            Some(MaterializationOwner::Session {
10343                incarnation_id,
10344                instance_id,
10345                generation,
10346            }) if incarnation_id == self.incarnation_id
10347                && instance_id == address.session.instance_id
10348                && generation == address.session.generation =>
10349            {
10350                self.cleanup_materialization(id)
10351            }
10352            Some(MaterializationOwner::Session { .. }) => Err(failure(
10353                NodeFailureCode::BackendOperationFailed,
10354                "session environment ownership requires recovery",
10355            )),
10356        }
10357    }
10358
10359    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10360        let mut watermarks = self
10361            .terminal_frame_watermarks
10362            .lock()
10363            .unwrap_or_else(|poisoned| poisoned.into_inner());
10364        if watermarks
10365            .get(&address.session.instance_id)
10366            .is_some_and(|(current, _)| current == address)
10367        {
10368            watermarks.remove(&address.session.instance_id);
10369        }
10370    }
10371
10372    #[cfg(test)]
10373    fn address_for(
10374        &self,
10375        instance_id: AgentInstanceId,
10376        generation: SessionGeneration,
10377    ) -> Option<SessionAddress> {
10378        let binding = self.session_bindings
10379            .lock()
10380            .unwrap_or_else(|poisoned| poisoned.into_inner())
10381            .get(&instance_id)
10382            .cloned()?;
10383        (binding.generation == generation).then_some(SessionAddress {
10384            workspace_id: binding.workspace_id,
10385            session: SessionKey { instance_id, generation },
10386        })
10387    }
10388
10389    fn arm_resume(
10390        &self,
10391        address: &SessionAddress,
10392        command_id: CommandId,
10393        runtime_policy: ProviderRuntimePolicy,
10394    ) -> Result<(), NodeFailure> {
10395        let mut bindings = self
10396            .session_bindings
10397            .lock()
10398            .unwrap_or_else(|poisoned| poisoned.into_inner());
10399        let binding = bindings
10400            .get_mut(&address.session.instance_id)
10401            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10402        if binding.workspace_id != address.workspace_id {
10403            return Err(failure(
10404                NodeFailureCode::SessionWorkspaceMismatch,
10405                "session belongs to another workspace",
10406            ));
10407        }
10408        if binding.generation != address.session.generation {
10409            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10410        }
10411        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10412        Ok(())
10413    }
10414
10415    fn clear_armed_resume(&self, address: &SessionAddress) {
10416        let mut bindings = self
10417            .session_bindings
10418            .lock()
10419            .unwrap_or_else(|poisoned| poisoned.into_inner());
10420        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10421            if binding.workspace_id == address.workspace_id
10422                && binding.generation == address.session.generation
10423            {
10424                binding.pending_resume = None;
10425            }
10426        }
10427    }
10428
10429    fn publish_control(&self, event: ControlEvent) {
10430        let revokes_harness_mcp = matches!(
10431            &event.event,
10432            ControlEventKind::Exited { .. }
10433                | ControlEventKind::Failed { .. }
10434                | ControlEventKind::Removed
10435        );
10436        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10437            let mut bindings = self
10438                .session_bindings
10439                .lock()
10440                .unwrap_or_else(|poisoned| poisoned.into_inner());
10441            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10442                return;
10443            };
10444            let previous_address = SessionAddress {
10445                workspace_id: binding.workspace_id.clone(),
10446                session: SessionKey {
10447                    instance_id: event.instance_id,
10448                    generation: binding.generation,
10449                },
10450            };
10451            let mut resume_command_rejected = false;
10452            if binding.generation == event.generation {
10453                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10454                    let rejected_resume = matches!(
10455                        &event.event,
10456                        ControlEventKind::CommandRejected { .. }
10457                    ) && event.command_id == Some(pending_command_id);
10458                    let resume_failed = matches!(
10459                        &event.event,
10460                        ControlEventKind::ResumeDenied { .. }
10461                            | ControlEventKind::ResumeFailed { .. }
10462                    );
10463                    if pending_generation == binding.generation
10464                        && (rejected_resume || resume_failed)
10465                    {
10466                        resume_command_rejected = rejected_resume;
10467                        binding.pending_resume = None;
10468                    }
10469                }
10470            } else {
10471                let expected = binding.generation.0.checked_add(1);
10472                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10473                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10474                    generation == binding.generation
10475                })
10476                    || expected != Some(event.generation.0)
10477                    || !authorized
10478                {
10479                    return;
10480                }
10481                let (_, _, runtime_policy) = binding
10482                    .pending_resume
10483                    .expect("authorized resume retains its pending runtime policy");
10484                binding.generation = event.generation;
10485                binding.runtime_policy = runtime_policy;
10486                binding.pending_resume = None;
10487            }
10488            (
10489                SessionAddress {
10490                    workspace_id: binding.workspace_id.clone(),
10491                    session: SessionKey {
10492                        instance_id: event.instance_id,
10493                        generation: event.generation,
10494                    },
10495                },
10496                (previous_address.session.generation != event.generation)
10497                    .then_some(previous_address),
10498                binding.record_id.clone(),
10499                binding.runtime_policy.provider_session_identity,
10500                resume_command_rejected,
10501            )
10502        };
10503        if let Some(previous_address) = previous_address.as_ref() {
10504            self.clear_terminal_frame_watermark(previous_address);
10505        }
10506        if let Some(record_id) = record_id {
10507            self.reconcile_managed_record(
10508                &record_id,
10509                &address,
10510                &event,
10511                identity_admitted,
10512                resume_command_rejected,
10513            );
10514        }
10515        // Every state change a session's process lifecycle can reach gets a
10516        // line here: this is the single chokepoint every `ControlEventKind`
10517        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10518        // came from a fresh spawn or a resume. Per-message provider chatter
10519        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10520        // matched here -- that is exactly the per-frame noise this crate
10521        // family's level discipline forbids at INFO.
10522        match &event.event {
10523            ControlEventKind::Running { process_id } => {
10524                #[cfg(unix)]
10525                if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10526                    proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10527                }
10528                tracing::info!(
10529                    node_id = %self.node_id,
10530                    workspace_id = %address.workspace_id,
10531                    session = ?address.session,
10532                    pid = ?process_id,
10533                    "session process is running",
10534                );
10535            }
10536            ControlEventKind::Exited { exit_code, forced } => {
10537                #[cfg(unix)]
10538                if let Some(proxy) = self.signer_proxy.get().cloned() {
10539                    let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10540                    if proxy.has_session(&key) {
10541                        // Revoke at core off the event path (blocking uplink call).
10542                        std::thread::spawn(move || proxy.release(&key));
10543                    }
10544                }
10545                tracing::info!(
10546                    node_id = %self.node_id,
10547                    workspace_id = %address.workspace_id,
10548                    session = ?address.session,
10549                    exit_code = ?exit_code,
10550                    forced = forced,
10551                    "session process exited",
10552                );
10553            }
10554            ControlEventKind::Failed { message } => {
10555                tracing::warn!(
10556                    node_id = %self.node_id,
10557                    workspace_id = %address.workspace_id,
10558                    session = ?address.session,
10559                    cause = %message,
10560                    "session failed",
10561                );
10562            }
10563            ControlEventKind::CommandRejected { message } => {
10564                tracing::warn!(
10565                    node_id = %self.node_id,
10566                    workspace_id = %address.workspace_id,
10567                    session = ?address.session,
10568                    cause = %message,
10569                    "session command rejected",
10570                );
10571            }
10572            ControlEventKind::Removed => {
10573                tracing::info!(
10574                    node_id = %self.node_id,
10575                    workspace_id = %address.workspace_id,
10576                    session = ?address.session,
10577                    "session removed",
10578                );
10579            }
10580            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10581                tracing::info!(
10582                    node_id = %self.node_id,
10583                    workspace_id = %address.workspace_id,
10584                    session = ?address.session,
10585                    provider_session = ?provider_session,
10586                    "session resume authorized",
10587                );
10588            }
10589            ControlEventKind::Resumed { session: provider_session, process_id } => {
10590                tracing::info!(
10591                    node_id = %self.node_id,
10592                    workspace_id = %address.workspace_id,
10593                    session = ?address.session,
10594                    provider_session = ?provider_session,
10595                    pid = ?process_id,
10596                    "session resumed",
10597                );
10598            }
10599            ControlEventKind::ResumeDenied { reason } => {
10600                tracing::warn!(
10601                    node_id = %self.node_id,
10602                    workspace_id = %address.workspace_id,
10603                    session = ?address.session,
10604                    cause = %reason,
10605                    "session resume denied",
10606                );
10607            }
10608            ControlEventKind::ResumeFailed { message } => {
10609                tracing::warn!(
10610                    node_id = %self.node_id,
10611                    workspace_id = %address.workspace_id,
10612                    session = ?address.session,
10613                    cause = %message,
10614                    "session resume failed",
10615                );
10616            }
10617            _ => {}
10618        }
10619        self.apply_pending_acp_options(&address, &event);
10620        let stream_chunk = agent_stream_chunk(&event);
10621        self.publish(NodeEvent::Control {
10622            address: address.clone(),
10623            event,
10624        });
10625        if let Some(chunk) = stream_chunk {
10626            self.publish_agent_stream_chunk(address.clone(), chunk);
10627        }
10628        if revokes_harness_mcp {
10629            SessionLifecycleHook::on_session_ended(self, &address);
10630        }
10631    }
10632
10633    fn reconcile_managed_record(
10634        &self,
10635        record_id: &SessionRecordId,
10636        address: &SessionAddress,
10637        event: &ControlEvent,
10638        identity_admitted: bool,
10639        resume_command_rejected: bool,
10640    ) {
10641        let _transaction = self
10642            .state_transaction
10643            .lock()
10644            .unwrap_or_else(|poisoned| poisoned.into_inner());
10645        let observed_identity = match &event.event {
10646            ControlEventKind::ProviderEvent {
10647                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10648                ..
10649            } if identity_admitted => Some(identity.clone()),
10650            _ => None,
10651        };
10652        let identity_was_observed = observed_identity.is_some();
10653        let replacement_id = observed_identity
10654            .as_ref()
10655            .and_then(|_| self.allocate_record_id().ok());
10656        let mut upserts = Vec::new();
10657        let mut removals = Vec::new();
10658        let mut rebind = None;
10659        let original_records;
10660        let original_materializations = self
10661            .materializations
10662            .lock()
10663            .unwrap_or_else(|poisoned| poisoned.into_inner())
10664            .clone();
10665        {
10666            let mut records = self
10667                .session_records
10668                .lock()
10669                .unwrap_or_else(|poisoned| poisoned.into_inner());
10670            original_records = records.records.clone();
10671            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10672                return;
10673            };
10674            if let Some(identity) = observed_identity {
10675                let existing_id = records
10676                    .records
10677                    .values()
10678                    .find(|record| {
10679                        record.record_id != *record_id
10680                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10681                                session_registry::same_provider_session(
10682                                    &record.provider,
10683                                    record_identity,
10684                                    &current_snapshot.provider,
10685                                    &identity,
10686                                )
10687                            })
10688                    })
10689                    .map(|record| record.record_id.clone());
10690                if let Some(existing_id) = existing_id {
10691                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10692                        record.mode == current_snapshot.mode
10693                            && record.workspace_id == current_snapshot.workspace_id
10694                            && record.environment_profile == current_snapshot.environment_profile
10695                            && record.bundle == current_snapshot.bundle
10696                            && record.context_id == current_snapshot.context_id
10697                            && record.context == current_snapshot.context
10698                            && record
10699                                .canonical_root
10700                                .as_utf8()
10701                                .zip(current_snapshot.canonical_root.as_utf8())
10702                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
10703                    });
10704                    if !scope_matches {
10705                        if let Some(current) = records.records.get_mut(record_id) {
10706                            current.state = ManagedSessionState::Unavailable;
10707                            current.last_error =
10708                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10709                            current.updated_at_unix_ms = unix_time_ms();
10710                            upserts.push(current.clone());
10711                        }
10712                    } else {
10713                        let existing_is_active = records
10714                            .records
10715                            .get(&existing_id)
10716                            .is_some_and(|record| record.active_session.is_some());
10717                        if existing_is_active {
10718                            if let Some(current) = records.records.get_mut(record_id) {
10719                                current.state = ManagedSessionState::Unavailable;
10720                                current.last_error =
10721                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10722                                current.updated_at_unix_ms = unix_time_ms();
10723                                upserts.push(current.clone());
10724                            }
10725                        } else {
10726                            if current_snapshot.provider_session.is_some() {
10727                                if let Some(current) = records.records.get_mut(record_id) {
10728                                    current.active_session = None;
10729                                    current.state = ManagedSessionState::Dormant;
10730                                    current.updated_at_unix_ms = unix_time_ms();
10731                                    upserts.push(current.clone());
10732                                }
10733                            } else {
10734                                records.records.remove(record_id);
10735                                removals.push(record_id.clone());
10736                            }
10737                            if let Some(existing) = records.records.get_mut(&existing_id) {
10738                                existing.provider_session = Some(identity.clone());
10739                                existing.active_session = Some(address.clone());
10740                                existing.state = ManagedSessionState::Live;
10741                                existing.updated_at_unix_ms = unix_time_ms();
10742                                existing.last_error = None;
10743                                upserts.push(existing.clone());
10744                            }
10745                            rebind = Some(existing_id);
10746                        }
10747                    }
10748                } else if current_snapshot
10749                    .provider_session
10750                    .as_ref()
10751                    .is_some_and(|current| {
10752                        !session_registry::same_provider_session(
10753                            &current_snapshot.provider,
10754                            current,
10755                            &current_snapshot.provider,
10756                            &identity,
10757                        )
10758                    })
10759                {
10760                    if let Some(current) = records.records.get_mut(record_id) {
10761                        current.active_session = None;
10762                        if current.bundle.is_some() || current.context.is_some() {
10763                            let had_bundle = current.bundle.is_some();
10764                            current.provider_session = None;
10765                            current.bundle = None;
10766                            current.context_id = None;
10767                            current.context = None;
10768                            current.state = ManagedSessionState::Unavailable;
10769                            current.last_error = Some(if had_bundle {
10770                                "bundle-unavailable".to_owned()
10771                            } else {
10772                                "context-unavailable".to_owned()
10773                            });
10774                        } else {
10775                            current.state = ManagedSessionState::Dormant;
10776                        }
10777                        current.updated_at_unix_ms = unix_time_ms();
10778                        upserts.push(current.clone());
10779                    }
10780                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10781                        if let Some(current) = records.records.get_mut(record_id) {
10782                            current.state = ManagedSessionState::Unavailable;
10783                            current.last_error =
10784                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10785                            current.updated_at_unix_ms = unix_time_ms();
10786                            upserts.push(current.clone());
10787                        }
10788                    } else if let Some(new_record_id) = replacement_id {
10789                        let now = unix_time_ms();
10790                        let replacement = ManagedSessionRecord {
10791                            record_id: new_record_id.clone(),
10792                            display_name: format!(
10793                                "{} #{}",
10794                                current_snapshot.provider.as_str(),
10795                                records
10796                                    .records
10797                                    .values()
10798                                    .filter(|record| {
10799                                        record.provider == current_snapshot.provider
10800                                    })
10801                                    .count()
10802                                    .saturating_add(1),
10803                            ),
10804                            provider: current_snapshot.provider.clone(),
10805                            mode: current_snapshot.mode,
10806                            state: ManagedSessionState::Live,
10807                            workspace_id: current_snapshot.workspace_id,
10808                            canonical_root: current_snapshot.canonical_root,
10809                            provider_session: Some(identity),
10810                            active_session: Some(address.clone()),
10811                            environment_profile: current_snapshot.environment_profile,
10812                            bundle: current_snapshot.bundle,
10813                            context_id: current_snapshot.context_id,
10814                            context: current_snapshot.context,
10815                            // This is a fresh record_id representing the session under its
10816                            // newly-observed identity; it has not yet produced any clean
10817                            // exit of its own, so it starts with no exported pack (the old
10818                            // record, still addressable under its own record_id, keeps
10819                            // whatever exported_context it already had).
10820                            exported_context: None,
10821                            task_binding: current_snapshot.task_binding,
10822                            created_at_unix_ms: now,
10823                            updated_at_unix_ms: now,
10824                            last_error: None,
10825                        };
10826                        records
10827                            .records
10828                            .insert(new_record_id.clone(), replacement.clone());
10829                        upserts.push(replacement);
10830                        rebind = Some(new_record_id);
10831                    } else if let Some(current) = records.records.get_mut(record_id) {
10832                        current.state = ManagedSessionState::Unavailable;
10833                        current.last_error =
10834                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10835                        upserts.push(current.clone());
10836                    }
10837                } else if let Some(current) = records.records.get_mut(record_id) {
10838                    current.provider_session = Some(identity);
10839                    current.active_session = Some(address.clone());
10840                    current.state = ManagedSessionState::Live;
10841                    current.updated_at_unix_ms = unix_time_ms();
10842                    current.last_error = None;
10843                    upserts.push(current.clone());
10844                }
10845            } else if let Some(current) = records.records.get_mut(record_id) {
10846                if resume_command_rejected {
10847                    current.active_session = None;
10848                    current.state = detached_record_state(current);
10849                    current.updated_at_unix_ms = unix_time_ms();
10850                    current.last_error =
10851                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10852                    upserts.push(current.clone());
10853                } else {
10854                    match &event.event {
10855                    ControlEventKind::Running { .. }
10856                    | ControlEventKind::ResumeAuthorized { .. }
10857                    | ControlEventKind::Resumed { .. } => {
10858                        current.active_session = Some(address.clone());
10859                        current.state = active_record_state(
10860                            identity_admitted,
10861                            current.provider_session.is_some(),
10862                        );
10863                        current.updated_at_unix_ms = unix_time_ms();
10864                        current.last_error = None;
10865                        upserts.push(current.clone());
10866                    }
10867                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10868                        current.active_session = None;
10869                        current.state = detached_record_state(current);
10870                        current.updated_at_unix_ms = unix_time_ms();
10871                        upserts.push(current.clone());
10872                    }
10873                    ControlEventKind::Failed { message }
10874                    | ControlEventKind::ResumeFailed { message } => {
10875                        current.active_session = None;
10876                        current.state = detached_record_state(current);
10877                        current.updated_at_unix_ms = unix_time_ms();
10878                        current.last_error = Some(
10879                            session_registry::sanitized_record_error_summary(message),
10880                        );
10881                        upserts.push(current.clone());
10882                    }
10883                    ControlEventKind::ResumeDenied { reason } => {
10884                        current.active_session = None;
10885                        current.state = detached_record_state(current);
10886                        current.updated_at_unix_ms = unix_time_ms();
10887                        current.last_error = Some(
10888                            session_registry::sanitized_record_error_summary(reason),
10889                        );
10890                        upserts.push(current.clone());
10891                    }
10892                        _ => {}
10893                    }
10894                }
10895            }
10896        }
10897        let materialization_owner_record = identity_was_observed.then(|| {
10898            rebind.clone().unwrap_or_else(|| record_id.clone())
10899        }).and_then(|candidate| {
10900            self.session_records
10901                .lock()
10902                .unwrap_or_else(|poisoned| poisoned.into_inner())
10903                .records
10904                .get(&candidate)
10905                .filter(|record| {
10906                    record.provider_session.is_some()
10907                        && record.active_session.as_ref() == Some(address)
10908                })
10909                .map(|_| candidate)
10910        });
10911        if let Some(owner_record_id) = materialization_owner_record {
10912            let materialization_id = self
10913                .session_bindings
10914                .lock()
10915                .unwrap_or_else(|poisoned| poisoned.into_inner())
10916                .get(&address.session.instance_id)
10917                .and_then(|binding| binding.materialization_id.clone());
10918            if let Some(materialization_id) = materialization_id {
10919                let mut transfer_failed = false;
10920                {
10921                    let mut materializations = self
10922                        .materializations
10923                        .lock()
10924                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10925                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
10926                        match ownership.owner() {
10927                            MaterializationOwner::Session {
10928                                incarnation_id,
10929                                instance_id,
10930                                generation,
10931                            } if *incarnation_id == self.incarnation_id
10932                                && *instance_id == address.session.instance_id
10933                                && *generation == address.session.generation =>
10934                            {
10935                                transfer_failed = ownership
10936                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10937                                    .is_err();
10938                            }
10939                            MaterializationOwner::Record { record_id }
10940                                if record_id == &owner_record_id => {}
10941                            MaterializationOwner::Record { record_id } => {
10942                                let expected = record_id.clone();
10943                                transfer_failed = ownership
10944                                    .transfer_record_owner(
10945                                        &expected,
10946                                        owner_record_id.clone(),
10947                                        unix_time_ms(),
10948                                    )
10949                                    .is_err();
10950                            }
10951                            _ => transfer_failed = true,
10952                        }
10953                        if transfer_failed {
10954                            let _ = ownership.mark_recovery_required(unix_time_ms());
10955                        }
10956                    } else {
10957                        transfer_failed = true;
10958                    }
10959                }
10960                if transfer_failed {
10961                    if let Some(record) = self
10962                        .session_records
10963                        .lock()
10964                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10965                        .records
10966                        .get_mut(&owner_record_id)
10967                    {
10968                        record.state = ManagedSessionState::Unavailable;
10969                        record.last_error = Some("environment-profile-unavailable".to_owned());
10970                        record.updated_at_unix_ms = unix_time_ms();
10971                        upserts.push(record.clone());
10972                    }
10973                }
10974            }
10975        }
10976        if let Some(new_record_id) = rebind.clone() {
10977            if let Some(binding) = self
10978                .session_bindings
10979                .lock()
10980                .unwrap_or_else(|poisoned| poisoned.into_inner())
10981                .get_mut(&address.session.instance_id)
10982            {
10983                binding.record_id = Some(new_record_id);
10984            }
10985        }
10986        if upserts.is_empty() && removals.is_empty() {
10987            return;
10988        }
10989        if self.persist_state_locked().is_err() {
10990            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10991            let mut records = self
10992                .session_records
10993                .lock()
10994                .unwrap_or_else(|poisoned| poisoned.into_inner());
10995            records.records = original_records;
10996            *self
10997                .materializations
10998                .lock()
10999                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11000            if let Some(current) = records.records.get_mut(record_id) {
11001                current.state = ManagedSessionState::Unavailable;
11002                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11003                current.updated_at_unix_ms = unix_time_ms();
11004                upserts = vec![current.clone()];
11005            } else {
11006                upserts.clear();
11007            }
11008            removals.clear();
11009            drop(records);
11010            if let Some(binding) = self
11011                .session_bindings
11012                .lock()
11013                .unwrap_or_else(|poisoned| poisoned.into_inner())
11014                .get_mut(&address.session.instance_id)
11015            {
11016                binding.record_id = Some(record_id.clone());
11017            }
11018            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11019        }
11020        for record in upserts {
11021            self.publish_record(record);
11022        }
11023        for record_id in removals {
11024            self.publish(NodeEvent::SessionRecordRemoved { record_id });
11025        }
11026    }
11027
11028    fn snapshot(&self) -> NodeSnapshot {
11029        let control = self.handle.snapshot();
11030        let bindings = self
11031            .session_bindings
11032            .lock()
11033            .unwrap_or_else(|poisoned| poisoned.into_inner())
11034            .clone();
11035        let workspace_roots = self
11036            .workspaces
11037            .read()
11038            .unwrap_or_else(|poisoned| poisoned.into_inner())
11039            .clone();
11040        let environment_profiles = self
11041            .environment_profiles
11042            .read()
11043            .unwrap_or_else(|poisoned| poisoned.into_inner());
11044        let mut workspaces = workspace_roots
11045            .iter()
11046            .map(|(workspace_id, canonical_root)| {
11047                (
11048                    workspace_id.clone(),
11049                    WorkspaceSnapshot {
11050                        workspace_id: workspace_id.clone(),
11051                        canonical_root: opaque_windows_path(canonical_root.clone()),
11052                        sessions: Vec::new(),
11053                        worktree_service_mode: Some(
11054                            self.worktree_service_modes
11055                                .get(workspace_id)
11056                                .copied()
11057                                .unwrap_or(WorktreeServiceMode::Manual),
11058                        ),
11059                        managed_worktree_profiles: Some(WorktreeProfileInventory {
11060                            profiles: self
11061                                .managed_worktree_profiles
11062                                .get(workspace_id)
11063                                .into_iter()
11064                                .flat_map(BTreeMap::values)
11065                                .map(|profile| ManagedWorktreeProfileSummary {
11066                                    id: profile.profile_id().clone(),
11067                                    revision: profile.revision().clone(),
11068                                    retention: profile.retention(),
11069                                })
11070                                .collect(),
11071                        }),
11072                    },
11073                )
11074            })
11075            .collect::<BTreeMap<_, _>>();
11076        let mut agent_progress = Vec::new();
11077        for session in &control.sessions {
11078            let Some(binding) = bindings.get(&session.instance_id) else {
11079                continue;
11080            };
11081            if binding.generation != session.generation {
11082                continue;
11083            }
11084            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11085                let address = SessionAddress {
11086                    workspace_id: binding.workspace_id.clone(),
11087                    session: SessionKey {
11088                        instance_id: session.instance_id,
11089                        generation: session.generation,
11090                    },
11091                };
11092                if let Some(progress) =
11093                    agent_progress_from_provider_snapshot(address, &session.provider)
11094                {
11095                    agent_progress.push(progress);
11096                }
11097                workspace.sessions.push(session.clone());
11098            }
11099        }
11100        NodeSnapshot {
11101            node_id: self.node_id.clone(),
11102            enabled_providers: self.enabled_providers.clone(),
11103            provider_runtime_statuses: {
11104                let updates = self
11105                    .provider_runtime_status_updates
11106                    .read()
11107                    .unwrap_or_else(|poisoned| poisoned.into_inner());
11108                ProviderRuntimeStatuses::new(
11109                    self.provider_runtime_statuses
11110                        .iter()
11111                        .filter(|status| !updates.contains_key(status.provider()))
11112                        .cloned()
11113                        .chain(updates.values().cloned()),
11114                )
11115                .expect("refreshed provider runtime inventory remains bounded and unique")
11116            },
11117            workspaces: workspaces.into_values().collect(),
11118            session_records: self
11119                .session_records
11120                .lock()
11121                .unwrap_or_else(|poisoned| poisoned.into_inner())
11122                .records
11123                .values()
11124                .cloned()
11125                .collect(),
11126            managed_worktrees: self.managed_worktrees
11127                .lock()
11128                .unwrap_or_else(|poisoned| poisoned.into_inner())
11129                .snapshots(),
11130            launch_inventory: Some(LaunchInventory {
11131                spawn_profiles: Some(
11132                    self.spawn_profiles
11133                        .iter()
11134                        .filter_map(|profile| {
11135                            resolved_spawn_profile_summary(profile, &environment_profiles)
11136                        })
11137                        .collect(),
11138                ),
11139                bundles: Some(
11140                    self.bundle_catalog
11141                        .read()
11142                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11143                        .iter()
11144                        .map(NodeBundle::receipt)
11145                        .collect(),
11146                ),
11147                // Opaque station allowlist ids from empty-default catalog /
11148                // optional startup loader. Never secrets. Dig2 probe stays stub.
11149                network_allowlists: {
11150                    let catalog = self
11151                        .network_allowlist_catalog
11152                        .read()
11153                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11154                    if catalog.is_empty() {
11155                        None
11156                    } else {
11157                        Some(catalog.ids().cloned().collect())
11158                    }
11159                },
11160            }),
11161            agent_progress,
11162        }
11163    }
11164
11165    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11166        assert!(
11167            !matches!(
11168                &event,
11169                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11170            ),
11171            "terminal frame and agent stream events must use their own dedicated live channel",
11172        );
11173        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11174        let sequence = history
11175            .last_sequence
11176            .checked_add(1)
11177            .expect("node event sequence exhausted");
11178        if history.events.len() == NODE_EVENT_HISTORY_MAX {
11179            if let Some(evicted) = history.events.pop_front() {
11180                history.replay_floor_sequence = evicted
11181                    .sequence
11182                    .checked_add(1)
11183                    .expect("node replay floor sequence exhausted");
11184                history.removed_record_providers.remove(&evicted.sequence);
11185            }
11186        }
11187        match &event {
11188            NodeEvent::HarnessMcpReadCall { .. } => {
11189                panic!("transient harness MCP calls must not enter durable event history")
11190            }
11191            NodeEvent::SessionRecordUpserted { record } => {
11192                history
11193                    .record_providers
11194                    .insert(record.record_id.clone(), record.provider.clone());
11195            }
11196            NodeEvent::SessionRecordRemoved { record_id } => {
11197                if let Some(provider) = history.record_providers.remove(record_id) {
11198                    history.removed_record_providers.insert(sequence, provider);
11199                }
11200            }
11201            NodeEvent::ManagedWorktreeUpserted { .. }
11202            | NodeEvent::ManagedWorktreeRemoved { .. }
11203            | NodeEvent::Control { .. }
11204            | NodeEvent::ControllerChanged { .. }
11205            | NodeEvent::SessionRecordHistorySummarized { .. }
11206            | NodeEvent::WorkspaceAdded { .. }
11207            | NodeEvent::WorkspaceRemoved { .. }
11208            | NodeEvent::ResyncRequired { .. }
11209            | NodeEvent::AgentStream { .. }
11210            | NodeEvent::TerminalFrame { .. } => {}
11211        }
11212        let envelope = NodeEventEnvelope { sequence, event };
11213        history.events.push_back(envelope.clone());
11214        history.last_sequence = sequence;
11215        let _ = self.event_tx.send(envelope.clone());
11216        drop(history);
11217        envelope
11218    }
11219
11220    /// Publishes a transient (never durable) `NodeEvent` -- today only
11221    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
11222    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
11223    /// why this must never ride the shared `event_tx` bus). Returns whether
11224    /// the send reached at least one subscriber: `broadcast::Sender::send`
11225    /// fails in exactly one way, no active receiver at that instant, and
11226    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
11227    /// return to fail the pending call immediately rather than leave it to
11228    /// expire silently at `LOCAL_READ_TIMEOUT`/
11229    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
11230    fn publish_transient(&self, event: NodeEvent) -> bool {
11231        assert!(event.requires_harness_mcp_proxy_capability());
11232        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11233        let envelope = NodeEventEnvelope { sequence: 0, event };
11234        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11235            Ok(_) => true,
11236            Err(broadcast::error::SendError(undelivered)) => {
11237                if let Some(NodeEventEnvelope {
11238                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11239                    ..
11240                }) = undelivered.first() {
11241                    // `request.content_type` only, never a per-tool label --
11242                    // this crate does not decode `request.body` and does not
11243                    // hold `hatchery-harness-api` (Nested Control Plane
11244                    // doctrine, Law 3; see this crate's dependency on
11245                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
11246                    tracing::warn!(
11247                        call_id = call_id.as_str(),
11248                        content_type = ?request.content_type,
11249                        cause = "no-subscribers",
11250                        "harness MCP proxy event could not be published",
11251                    );
11252                }
11253                false
11254            }
11255        }
11256    }
11257
11258    fn publish_terminal_frames(&self) {
11259        let snapshot = self.handle.snapshot();
11260        let bindings = self
11261            .session_bindings
11262            .lock()
11263            .unwrap_or_else(|poisoned| poisoned.into_inner());
11264        let candidates = snapshot
11265            .sessions
11266            .iter()
11267            .filter_map(|session| {
11268                let binding = bindings.get(&session.instance_id)?;
11269                if binding.generation != session.generation {
11270                    return None;
11271                }
11272                let frame = session.terminal_frame.clone()?;
11273                crate::session_signals::feed(
11274                    binding.workspace_id.as_str(),
11275                    session.instance_id.0,
11276                    session.agent_id.as_str(),
11277                    &frame.contents,
11278                );
11279                Some((
11280                    SessionAddress {
11281                        workspace_id: binding.workspace_id.clone(),
11282                        session: SessionKey {
11283                            instance_id: session.instance_id,
11284                            generation: session.generation,
11285                        },
11286                    },
11287                    frame,
11288                ))
11289            })
11290            .collect();
11291        drop(bindings);
11292        self.publish_terminal_frame_candidates(candidates);
11293    }
11294
11295    fn publish_terminal_frame_candidates(
11296        &self,
11297        candidates: Vec<(SessionAddress, TerminalFrame)>,
11298    ) {
11299        let mut watermarks = self
11300            .terminal_frame_watermarks
11301            .lock()
11302            .unwrap_or_else(|poisoned| poisoned.into_inner());
11303        let mut events = Vec::with_capacity(candidates.len());
11304        for (address, frame) in candidates {
11305            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11306            let advanced = watermarks
11307                .get(&address.session.instance_id)
11308                .map_or(true, |(current_address, sequence)| {
11309                    current_address != &address || frame.sequence > *sequence
11310                });
11311            if !advanced {
11312                continue;
11313            }
11314            if is_first_frame {
11315                // Do NOT sessionize on first PTY output: a session is a
11316                // provider conversation id, not bytes on the wire. Drafts
11317                // leave the provisional map only when HQ attaches
11318                // (Resize / TerminalBytes). The attach-never-arrived timer
11319                // (PROVISIONAL_SPAWN_GRACE_MS) covers deploy lag; HQ's
11320                // disconnect grace starts only on a real client WS close.
11321                // This is the first PTY output this node has observed for
11322                // this session -- a one-time event per spawn, not per-frame
11323                // chatter (the watermark makes sure of that). A session that
11324                // reaches this line with no controller attached anywhere on
11325                // the node is running unobserved: exactly how processes from
11326                // repeated spawn attempts went orphaned for hours without a
11327                // single line about it.
11328                if self.controller_state().is_none() {
11329                    tracing::warn!(
11330                        node_id = %self.node_id,
11331                        workspace_id = %address.workspace_id,
11332                        session = ?address.session,
11333                        terminal_size = ?frame.size,
11334                        "session producing output with no controller attached",
11335                    );
11336                } else {
11337                    tracing::info!(
11338                        node_id = %self.node_id,
11339                        workspace_id = %address.workspace_id,
11340                        session = ?address.session,
11341                        terminal_size = ?frame.size,
11342                        "session produced its first output",
11343                    );
11344                }
11345            }
11346            watermarks.insert(
11347                address.session.instance_id,
11348                (address.clone(), frame.sequence),
11349            );
11350            if let Some((at, reported)) = self
11351                .pty_input_marks
11352                .lock()
11353                .unwrap_or_else(|poisoned| poisoned.into_inner())
11354                .get_mut(&address.session.instance_id)
11355            {
11356                // First frame after an input: PTY write -> output published.
11357                if !*reported && at.elapsed() < Duration::from_secs(5) {
11358                    *reported = true;
11359                    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");
11360                }
11361            }
11362            events.push(NodeEvent::TerminalFrame { address, frame });
11363        }
11364        drop(watermarks);
11365        if events.is_empty() {
11366            return;
11367        }
11368        let mut history = self
11369            .history
11370            .lock()
11371            .unwrap_or_else(|poisoned| poisoned.into_inner());
11372        let mut envelopes = Vec::with_capacity(events.len());
11373        for event in events {
11374            let sequence = history
11375                .last_sequence
11376                .checked_add(1)
11377                .expect("node event sequence exhausted");
11378            history.last_sequence = sequence;
11379            envelopes.push(NodeEventEnvelope { sequence, event });
11380        }
11381        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11382        drop(history);
11383    }
11384
11385    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11386    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11387    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11388    /// counter every other event kind shares, never a counter of its own --
11389    /// `queue_connection_event`/`queue_connection_event_batch` compare
11390    /// `envelope.sequence` against one `discard_events_through` watermark
11391    /// regardless of which broadcast channel it arrived on, so a
11392    /// per-channel counter would desynchronize that comparison.
11393    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11394        let mut history = self
11395            .history
11396            .lock()
11397            .unwrap_or_else(|poisoned| poisoned.into_inner());
11398        let sequence = history
11399            .last_sequence
11400            .checked_add(1)
11401            .expect("node event sequence exhausted");
11402        history.last_sequence = sequence;
11403        drop(history);
11404        // Debug, not info/warn -- this fires on every single chunk, but it
11405        // is exactly the counter the next drop-measurement pass needs: what
11406        // the node actually emitted (`source_sequence`, `kind`) versus what
11407        // a harness subscriber's own forwarder later logs having received
11408        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11409        // event"), so a gap between the two logs names itself instead of
11410        // needing a new counter added ad hoc.
11411        tracing::debug!(
11412            source_sequence = chunk.source_sequence,
11413            kind = agent_stream_chunk_kind_label(&chunk.kind),
11414            envelope_sequence = sequence,
11415            "publishing agent stream chunk",
11416        );
11417        let envelope = NodeEventEnvelope {
11418            sequence,
11419            event: NodeEvent::AgentStream { address, chunk },
11420        };
11421        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11422    }
11423
11424    fn current_sequence(&self) -> u64 {
11425        self.history
11426            .lock()
11427            .unwrap_or_else(|poisoned| poisoned.into_inner())
11428            .last_sequence
11429    }
11430
11431    fn controller_state(&self) -> Option<ControllerState> {
11432        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11433        let lease = controller.as_ref().copied()?;
11434        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11435        if remaining.is_zero() {
11436            *controller = None;
11437            return None;
11438        }
11439        Some(ControllerState {
11440            connection_id: lease.connection_id,
11441            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11442        })
11443    }
11444
11445    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11446        if role != ClientRole::Operator {
11447            tracing::warn!(
11448                node_id = %self.node_id,
11449                connection_id,
11450                "controller lease rejected: observer connections cannot acquire control",
11451            );
11452            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11453        }
11454        let lease_ms = if lease_ms == 0 {
11455            DEFAULT_CONTROLLER_LEASE_MS
11456        } else {
11457            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11458        };
11459        let now = Instant::now();
11460        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11461        if let Some(active) = controller.as_ref() {
11462            if active.connection_id != connection_id && active.expires_at > now {
11463                tracing::warn!(
11464                    node_id = %self.node_id,
11465                    connection_id,
11466                    holder_connection_id = active.connection_id,
11467                    "controller lease rejected: another operator holds it",
11468                );
11469                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11470            }
11471        }
11472        // A reconnect (this connection already holds the lease, renewing
11473        // before expiry) happens on a periodic cadence; logging it at INFO
11474        // every renewal would be exactly the heartbeat chatter this crate
11475        // family's level discipline forbids. A genuine attach -- the
11476        // controller identity actually changing -- is a state change and
11477        // gets INFO; a same-connection renewal gets DEBUG.
11478        let is_new_attach = match controller.as_ref() {
11479            Some(active) => active.connection_id != connection_id,
11480            None => true,
11481        };
11482        *controller = Some(ControllerLease {
11483            connection_id,
11484            expires_at: now + Duration::from_millis(lease_ms),
11485        });
11486        drop(controller);
11487        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11488        if is_new_attach {
11489            tracing::info!(
11490                node_id = %self.node_id,
11491                connection_id,
11492                lease_ms,
11493                "controller lease acquired",
11494            );
11495        } else {
11496            tracing::debug!(
11497                node_id = %self.node_id,
11498                connection_id,
11499                lease_ms,
11500                "controller lease renewed",
11501            );
11502        }
11503        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11504        Ok(state)
11505    }
11506
11507    fn release_controller(&self, connection_id: u64) -> bool {
11508        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11509        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11510        if released {
11511            *controller = None;
11512        }
11513        drop(controller);
11514        if released {
11515            tracing::info!(
11516                node_id = %self.node_id,
11517                connection_id,
11518                "controller lease released",
11519            );
11520            self.publish(NodeEvent::ControllerChanged { controller: None });
11521        }
11522        released
11523    }
11524
11525    /// Mark a fresh Spawn as provisional and arm the attach-never-arrived
11526    /// reaper (PROVISIONAL_SPAWN_GRACE_MS from spawn). Removed from this map
11527    /// only when HQ attaches (Resize / TerminalBytes) — that is *not* a
11528    /// provider session; it only cancels the orphan timer. Disconnect/reload
11529    /// draft kills are owned by HQ and arm only on a real client WS close.
11530    /// ACP path of launch options: once the agent advertises its config options, write the
11531    /// pending model / effort through session/set_config_option (session/set_model when the
11532    /// agent has no model option). Gaps are logged, never forced.
11533    fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11534        let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11535            return;
11536        };
11537        let id = address.session.instance_id;
11538        let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11539            return;
11540        };
11541        let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11542            .iter()
11543            .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11544            .collect();
11545        let (writes, set_model, effort_gap) =
11546            backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11547        if set_model {
11548            if let Some(model_id) = model.clone() {
11549                if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11550                    tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11551                }
11552            }
11553        }
11554        for (option_id, value_json) in writes {
11555            if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11556                tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11557            }
11558        }
11559        if effort_gap {
11560            tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11561        }
11562        tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11563    }
11564
11565    fn mark_spawn_provisional(&self, address: SessionAddress) {
11566        let id = address.session.instance_id;
11567        self.provisional_spawns
11568            .lock()
11569            .unwrap_or_else(|poisoned| poisoned.into_inner())
11570            .insert(id, (address, Instant::now()));
11571        tracing::info!(
11572            node_id = %self.node_id,
11573            instance_id = id.0,
11574            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11575            "draft spawn; waiting for HQ view",
11576        );
11577    }
11578
11579    fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11580        let removed = self
11581            .provisional_spawns
11582            .lock()
11583            .unwrap_or_else(|poisoned| poisoned.into_inner())
11584            .remove(&instance_id);
11585        if removed.is_some() {
11586            tracing::info!(
11587                node_id = %self.node_id,
11588                instance_id = instance_id.0,
11589                "draft spawn claimed by HQ view",
11590            );
11591        }
11592    }
11593
11594    fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11595        let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11596        let mut map = self
11597            .provisional_spawns
11598            .lock()
11599            .unwrap_or_else(|poisoned| poisoned.into_inner());
11600        let due: Vec<AgentInstanceId> = map
11601            .iter()
11602            .filter(|(_, (_, at))| at.elapsed() >= grace)
11603            .map(|(id, _)| *id)
11604            .collect();
11605        due.into_iter()
11606            .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11607            .collect()
11608    }
11609
11610    async fn reap_provisional_spawn(&self, address: SessionAddress) {
11611        let instance_id = address.session.instance_id;
11612        tracing::warn!(
11613            node_id = %self.node_id,
11614            instance_id = instance_id.0,
11615            workspace = %address.workspace_id,
11616            reason = "attach_never_arrived",
11617            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11618            "draft spawn never viewed; stopping PTY",
11619        );
11620        let _ = self.dispatch(ControlCommand::Stop {
11621            instance_id,
11622            force: true,
11623        });
11624        let _ = self.remove_session(&address).await;
11625    }
11626
11627    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11628        if role != ClientRole::Operator {
11629            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11630        }
11631        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11632        let now = Instant::now();
11633        match controller.as_ref().copied() {
11634            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11635            Some(active) if active.expires_at <= now => {
11636                *controller = None;
11637                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11638            }
11639            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11640        }
11641    }
11642
11643    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11644        if !self
11645            .workspaces
11646            .read()
11647            .unwrap_or_else(|poisoned| poisoned.into_inner())
11648            .contains_key(&address.workspace_id)
11649        {
11650            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11651        }
11652        let binding = self
11653            .session_bindings
11654            .lock()
11655            .unwrap_or_else(|poisoned| poisoned.into_inner())
11656            .get(&address.session.instance_id)
11657            .cloned()
11658            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11659        if binding.workspace_id != address.workspace_id {
11660            return Err(failure(
11661                NodeFailureCode::SessionWorkspaceMismatch,
11662                "session belongs to another workspace",
11663            ));
11664        }
11665        if binding.generation != address.session.generation {
11666            return Err(failure(
11667                NodeFailureCode::StaleGeneration,
11668                &format!(
11669                    "stale session generation: expected {}, received {}",
11670                    binding.generation.0,
11671                    address.session.generation.0,
11672                ),
11673            ));
11674        }
11675        let snapshot = self.handle.snapshot();
11676        let Some(current) = snapshot
11677            .sessions
11678            .iter()
11679            .find(|item| item.instance_id == address.session.instance_id)
11680        else {
11681            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11682        };
11683        if current.generation != address.session.generation {
11684            return Err(failure(
11685                NodeFailureCode::StaleGeneration,
11686                &format!(
11687                    "stale session generation: expected {}, received {}",
11688                    current.generation.0,
11689                    address.session.generation.0,
11690                ),
11691            ));
11692        }
11693        Ok(current.agent_id.clone())
11694    }
11695
11696    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11697        self.dispatch_envelope(self.prepare_command(command))
11698    }
11699
11700    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11701        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11702        CommandEnvelope {
11703            id: CommandId(id),
11704            command,
11705        }
11706    }
11707
11708    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11709        let command_id = envelope.id;
11710        self.handle
11711            .dispatch(envelope)
11712            .map_err(|error| match error {
11713                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11714                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11715            })?;
11716        Ok(command_id)
11717    }
11718
11719    /// Records the kernel's own reason for asynchronously refusing
11720    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
11721    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
11722    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
11723    /// first, and if the map is still at capacity after that, the single
11724    /// oldest surviving entry is evicted -- `CommandId` is monotonically
11725    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
11726    /// is always the oldest.
11727    fn record_rejected_command(
11728        &self,
11729        command_id: CommandId,
11730        message: String,
11731        unsupported_transport: bool,
11732    ) {
11733        let now = Instant::now();
11734        let mut reasons = self.rejected_command_reasons.lock()
11735            .unwrap_or_else(|poisoned| poisoned.into_inner());
11736        reasons.retain(|_, reason| reason.expires_at > now);
11737        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11738            if let Some(&oldest) = reasons.keys().next() {
11739                reasons.remove(&oldest);
11740            }
11741        }
11742        reasons.insert(
11743            command_id,
11744            RejectedCommandReason {
11745                message,
11746                unsupported_transport,
11747                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11748            },
11749        );
11750    }
11751
11752    /// Removes and returns the kernel's reason for refusing `command_id`,
11753    /// if the drive loop has recorded one and it has not expired -- see
11754    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
11755    /// wait loop calls this instead of blindly polling out its whole
11756    /// deadline when the command it is waiting on turns out to have been
11757    /// refused rather than merely slow.
11758    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11759        let now = Instant::now();
11760        let mut reasons = self.rejected_command_reasons.lock()
11761            .unwrap_or_else(|poisoned| poisoned.into_inner());
11762        match reasons.remove(&command_id) {
11763            Some(reason) if reason.expires_at > now => Some(reason),
11764            _ => None,
11765        }
11766    }
11767
11768    async fn dispatch_bounded(
11769        &self,
11770        command: ControlCommand,
11771        timeout_duration: Duration,
11772    ) -> Result<CommandId, NodeFailure> {
11773        let deadline = Instant::now() + timeout_duration;
11774        loop {
11775            match self.dispatch(command.clone()) {
11776                Ok(command_id) => return Ok(command_id),
11777                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11778                    sleep(Duration::from_millis(2)).await;
11779                }
11780                Err(error) => return Err(error),
11781            }
11782        }
11783    }
11784
11785    fn internal_session_snapshot(
11786        &self,
11787        address: &SessionAddress,
11788    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11789        self.validate_address(address)?;
11790        self.handle
11791            .snapshot()
11792            .sessions
11793            .clone()
11794            .into_iter()
11795            .find(|session| {
11796                session.instance_id == address.session.instance_id
11797                    && session.generation == address.session.generation
11798            })
11799            .ok_or_else(|| {
11800                failure(
11801                    NodeFailureCode::UnknownSession,
11802                    "session snapshot is unavailable",
11803                )
11804            })
11805    }
11806
11807    /// Confirms `address` names a live session spawned over the ACP
11808    /// transport and returns its current snapshot -- the gate every
11809    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
11810    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
11811    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
11812    /// the session's own recorded `TransportKind` does, and that lives on
11813    /// the live snapshot, not the binding.
11814    fn require_acp_session(
11815        &self,
11816        address: &SessionAddress,
11817    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11818        let snapshot = self.internal_session_snapshot(address)?;
11819        if snapshot.transport != TransportKind::Acp {
11820            return Err(failure(
11821                NodeFailureCode::UnsupportedTransport,
11822                "this verb requires a session spawned over the ACP transport",
11823            ));
11824        }
11825        Ok(snapshot)
11826    }
11827
11828    async fn dispatch_history_bounded(
11829        &self,
11830        address: &SessionAddress,
11831        command: ControlCommand,
11832        operation: HistoryOperation,
11833    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11834        self.validate_address(address)?;
11835        let started_after = self.current_sequence();
11836        let command_id = self
11837            .dispatch_bounded(
11838                command,
11839                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11840            )
11841            .await?;
11842        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11843        let mut scan_after = started_after;
11844        let mut accepted_after = None;
11845        loop {
11846            let events = self
11847                .history
11848                .lock()
11849                .unwrap_or_else(|poisoned| poisoned.into_inner())
11850                .events
11851                .iter()
11852                .filter(|envelope| envelope.sequence > scan_after)
11853                .cloned()
11854                .collect::<Vec<_>>();
11855            for envelope in events {
11856                scan_after = scan_after.max(envelope.sequence);
11857                let NodeEvent::Control {
11858                    address: event_address,
11859                    event,
11860                } = envelope.event
11861                else {
11862                    continue;
11863                };
11864                if event_address != *address {
11865                    continue;
11866                }
11867                if event.command_id == Some(command_id) {
11868                    match &event.event {
11869                        ControlEventKind::CommandRejected { .. } => {
11870                            return Err(failure(
11871                                NodeFailureCode::InvalidRequest,
11872                                "history request was rejected",
11873                            ));
11874                        }
11875                        ControlEventKind::HistoryRequested {
11876                            operation: requested,
11877                            ..
11878                        } if requested == &operation => {
11879                            accepted_after = Some(envelope.sequence);
11880                            continue;
11881                        }
11882                        _ => {}
11883                    }
11884                }
11885                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11886                    match (&operation, &event.event) {
11887                        (
11888                            HistoryOperation::Discover { .. },
11889                            ControlEventKind::HistoryDiscovered { .. },
11890                        )
11891                        | (
11892                            HistoryOperation::Load { .. },
11893                            ControlEventKind::HistoryLoaded { .. },
11894                        ) => return self.internal_session_snapshot(address),
11895                        (_, ControlEventKind::HistoryFailed { .. }) => {
11896                            return Err(failure(
11897                                NodeFailureCode::BackendOperationFailed,
11898                                "history operation failed",
11899                            ));
11900                        }
11901                        _ => {}
11902                    }
11903                }
11904            }
11905            if Instant::now() >= deadline {
11906                return Err(failure(
11907                    NodeFailureCode::BackendBusy,
11908                    if accepted_after.is_some() {
11909                        "history operation did not settle before the bounded deadline"
11910                    } else {
11911                        "history operation was not accepted before the bounded deadline"
11912                    },
11913                ));
11914            }
11915            sleep(Duration::from_millis(2)).await;
11916        }
11917    }
11918
11919    async fn discover_history(
11920        &self,
11921        address: &SessionAddress,
11922        limit: u16,
11923    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11924        let query = HistoryQuery {
11925            working_directory: Some(self.workspace_root(&address.workspace_id)?),
11926            limit,
11927        };
11928        query
11929            .validate()
11930            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11931        let snapshot = self
11932            .dispatch_history_bounded(
11933                address,
11934                ControlCommand::DiscoverHistory {
11935                    instance_id: address.session.instance_id,
11936                    query: query.clone(),
11937                },
11938                HistoryOperation::Discover { query },
11939            )
11940            .await?;
11941        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11942            return Err(failure(
11943                NodeFailureCode::BackendOperationFailed,
11944                "history discovery settled into an invalid state",
11945            ));
11946        }
11947        Ok(snapshot.history.candidates)
11948    }
11949
11950    async fn load_history(
11951        &self,
11952        address: &SessionAddress,
11953        candidate_id: String,
11954    ) -> Result<HistorySessionRecord, NodeFailure> {
11955        validate_candidate_id(&candidate_id)
11956            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11957        let snapshot = self
11958            .dispatch_history_bounded(
11959                address,
11960                ControlCommand::LoadHistory {
11961                    instance_id: address.session.instance_id,
11962                    candidate_id: candidate_id.clone(),
11963                },
11964                HistoryOperation::Load {
11965                    candidate_id: candidate_id.clone(),
11966                },
11967            )
11968            .await?;
11969        if snapshot.history.pending.is_some()
11970            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11971        {
11972            return Err(failure(
11973                NodeFailureCode::BackendOperationFailed,
11974                "history load settled into an invalid state",
11975            ));
11976        }
11977        snapshot.history.loaded.ok_or_else(|| {
11978            failure(
11979                NodeFailureCode::BackendOperationFailed,
11980                "history load produced no bounded session",
11981            )
11982        })
11983    }
11984
11985    /// `allow_clean_detachment`: `false` for the on-demand
11986    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
11987    /// managed record must stay untouched, still live, for the whole call);
11988    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
11989    /// reacts to the very clean-exit event that detaches the record out from
11990    /// under this same call — see `session_record_export_target_matches`.
11991    async fn export_context_pack_for_session_record(
11992        &self,
11993        record_id: &SessionRecordId,
11994        session: &SessionAddress,
11995        allow_clean_detachment: bool,
11996    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11997        // C2 replaces a relayed failure's prose with a canned per-code
11998        // string (`C2NodeFailure`'s own `From`), so this path's dozen
11999        // distinct refusals are indistinguishable to anyone downstream --
12000        // and a refusal here expires a continuation, which keeps no field
12001        // for why. The prose stays on the node, where it was written.
12002        let outcome = self
12003            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12004            .await;
12005        if let Err(failure) = &outcome {
12006            tracing::warn!(
12007                record = %record_id,
12008                code = ?failure.code,
12009                reason = %failure.message,
12010                "session-record context-pack export refused",
12011            );
12012        }
12013        outcome
12014    }
12015
12016    async fn export_context_pack_for_session_record_inner(
12017        &self,
12018        record_id: &SessionRecordId,
12019        session: &SessionAddress,
12020        allow_clean_detachment: bool,
12021    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12022        // Several steps here refuse with the same BackendOperationFailed
12023        // code and no prose of their own, so which step failed is
12024        // otherwise unrecoverable -- and a refusal here expires a
12025        // continuation.
12026        let step = |name: &'static str| move |failure: NodeFailure| {
12027            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12028            failure
12029        };
12030        let record = self.record(record_id).map_err(step("record-lookup"))?;
12031        // `provider_session` is `Some` only for a provider whose resolved
12032        // runtime policy admits live identity verification (raw PTY plus a
12033        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
12034        // The PTY-sidecar-observation shape (no fleet provider today, but
12035        // capability-keyed rather than vendor-locked) hardcodes
12036        // `provider_session_identity: false` in `provider_runtime::evaluate`,
12037        // so its records go straight to `Live` with `provider_session: None`
12038        // and stay that way for the record's entire life -- that is this
12039        // shape's permanent resting state, not a race with observation, and
12040        // `discover_history`/`load_history` below do not need an identity to
12041        // do their job for it. `IdentityPending` is the actual race (an
12042        // identity-admitted provider whose observation has not landed yet):
12043        // it keeps refusing here rather than falling back, because that
12044        // provider's own history the fallback would otherwise trust is not
12045        // guaranteed to be exact without the identity it is still waiting on.
12046        let identity = match record.provider_session.clone() {
12047            Some(identity) => {
12048                if identity.key != ProviderSessionKey::SessionId {
12049                    return Err(step("provider-identity-key")(failure(
12050                        NodeFailureCode::BackendOperationFailed,
12051                        "managed session provider identity cannot bind history exactly",
12052                    )));
12053                }
12054                Some(identity)
12055            }
12056            None if record.state == ManagedSessionState::IdentityPending => {
12057                return Err(step("provider-session-identity")(failure(
12058                    NodeFailureCode::BackendOperationFailed,
12059                    "managed session has not yet observed its provider session identity",
12060                )));
12061            }
12062            None => None,
12063        };
12064        self.revalidate_session_record_context_export(
12065            &record,
12066            identity.as_ref(),
12067            session,
12068            allow_clean_detachment,
12069        ).map_err(step("revalidate-before-discovery"))?;
12070        let candidates = self
12071            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12072            .await
12073            .map_err(step("discover-history"))?;
12074        // With no verified identity to filter by, the discovered set must
12075        // still resolve to exactly one candidate: the same ambiguity refusal
12076        // an identity filter would otherwise produce is the only safety
12077        // property left available for this shape.
12078        let candidate_id = select_history_candidate(
12079            &candidates,
12080            identity.as_ref().map(|identity| identity.id.as_str()),
12081        ).map_err(step("unique-candidate"))?;
12082        let loaded = self.load_history(session, candidate_id.clone()).await
12083            .map_err(step("load-history"))?;
12084        if let Some(identity) = identity.as_ref() {
12085            if loaded.session_id != identity.id {
12086                return Err(failure(
12087                    NodeFailureCode::SessionRecordConflict,
12088                    "loaded history identity does not match the managed session record",
12089                ));
12090            }
12091        }
12092        self.revalidate_session_record_context_export(
12093            &record,
12094            identity.as_ref(),
12095            session,
12096            allow_clean_detachment,
12097        )?;
12098        self.revalidate_loaded_history(session, &candidate_id, &loaded)
12099            .map_err(step("revalidate-loaded-history"))?;
12100        let pack = self.materialize_context_pack(session).await
12101            .map_err(step("materialize-context-pack"))?;
12102        self.commit_context_pack_for_session_record(
12103            &record,
12104            identity.as_ref(),
12105            session,
12106            &candidate_id,
12107            &loaded,
12108            pack,
12109            allow_clean_detachment,
12110        )
12111    }
12112
12113    async fn export_context_pack(
12114        &self,
12115        address: &SessionAddress,
12116    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12117        let pack = self.materialize_context_pack(address).await?;
12118        self.commit_context_pack(pack)
12119    }
12120
12121    async fn materialize_context_pack(
12122        &self,
12123        address: &SessionAddress,
12124    ) -> Result<NodeContextPack, NodeFailure> {
12125        let snapshot = self.internal_session_snapshot(address)?;
12126        if !context_pack_source_status_is_usable(&snapshot.status) {
12127            return Err(failure(
12128                NodeFailureCode::InvalidRequest,
12129                "context export requires a running or successfully exited source session",
12130            ));
12131        }
12132        if snapshot.history.pending.is_some()
12133            || snapshot.history.loaded_candidate_id.is_none()
12134        {
12135            return Err(failure(
12136                NodeFailureCode::InvalidRequest,
12137                "context export requires settled loaded history",
12138            ));
12139        }
12140        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12141            failure(
12142                NodeFailureCode::InvalidRequest,
12143                "context export requires settled loaded history",
12144            )
12145        })?;
12146        let source_provider = snapshot.agent_id.clone();
12147        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12148        let repository = self
12149            .context_pack_repository(&address.workspace_id)
12150            .await?;
12151        let current = self.internal_session_snapshot(address)?;
12152        if !context_pack_source_status_is_usable(&current.status)
12153            || current.agent_id != source_provider
12154            || current.history.pending.is_some()
12155            || current.history.loaded_candidate_id != loaded_candidate_id
12156            || current.history.loaded.as_ref() != Some(&loaded)
12157        {
12158            return Err(failure(
12159                NodeFailureCode::ContextPackMaterializationFailed,
12160                "context source changed during bounded repository capture",
12161            ));
12162        }
12163        NodeContextPack::export_with_repository(
12164            ContextPackLineageReceipt {
12165                source_node_id: self.node_id.clone(),
12166                source_session: address.clone(),
12167                source_provider,
12168            },
12169            &loaded,
12170            Some(repository),
12171        )
12172        .map_err(|_| {
12173            failure(
12174                NodeFailureCode::ContextPackMaterializationFailed,
12175                "bounded context pack export failed",
12176            )
12177        })
12178    }
12179
12180    /// Write-through to the durable [`ContextPackStore`], if one is
12181    /// configured (`state_path` is `Some`). A node with no durable state
12182    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
12183    /// open at all and this is a no-op, matching `persist_state`'s own
12184    /// "durability optional, degrade gracefully when unconfigured" idiom.
12185    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12186        let mut store = self
12187            .context_pack_store
12188            .lock()
12189            .unwrap_or_else(|poisoned| poisoned.into_inner());
12190        let Some(store) = store.as_mut() else {
12191            return Ok(());
12192        };
12193        store.commit(pack).map_err(|_| {
12194            failure(
12195                NodeFailureCode::ContextPackMaterializationFailed,
12196                "durable context pack commit failed",
12197            )
12198        })
12199    }
12200
12201    fn commit_context_pack(
12202        &self,
12203        pack: NodeContextPack,
12204    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12205        self.commit_context_pack_durably(&pack)?;
12206        self.context_catalog
12207            .write()
12208            .unwrap_or_else(|poisoned| poisoned.into_inner())
12209            .insert(pack)
12210            .map_err(|_| {
12211                failure(
12212                    NodeFailureCode::ContextPackMaterializationFailed,
12213                    "context pack catalog is full",
12214                )
12215            })
12216    }
12217
12218    fn commit_context_pack_for_session_record(
12219        &self,
12220        expected: &ManagedSessionRecord,
12221        identity: Option<&ProviderSessionIdentity>,
12222        session: &SessionAddress,
12223        candidate_id: &str,
12224        loaded: &HistorySessionRecord,
12225        pack: NodeContextPack,
12226        allow_clean_detachment: bool,
12227    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12228        let current_root = self.workspace_root(&expected.workspace_id)?;
12229        let _transaction = self
12230            .state_transaction
12231            .lock()
12232            .unwrap_or_else(|poisoned| poisoned.into_inner());
12233        let records = self
12234            .session_records
12235            .lock()
12236            .unwrap_or_else(|poisoned| poisoned.into_inner());
12237        let current = records.records.get(&expected.record_id).ok_or_else(|| {
12238            failure(
12239                NodeFailureCode::SessionRecordConflict,
12240                "managed session record disappeared during context export",
12241            )
12242        })?;
12243        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12244            return Err(failure(
12245                NodeFailureCode::SessionRecordConflict,
12246                "managed session record changed during context export",
12247            ));
12248        }
12249        let bindings = self
12250            .session_bindings
12251            .lock()
12252            .unwrap_or_else(|poisoned| poisoned.into_inner());
12253        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12254            failure(
12255                NodeFailureCode::SessionRecordConflict,
12256                "managed session runtime binding disappeared during context export",
12257            )
12258        })?;
12259        let runtime = self
12260            .handle
12261            .snapshot()
12262            .sessions
12263            .iter()
12264            .find(|snapshot| {
12265                snapshot.instance_id == session.session.instance_id
12266                    && snapshot.generation == session.session.generation
12267            })
12268            .cloned()
12269            .ok_or_else(|| {
12270                failure(
12271                    NodeFailureCode::SessionRecordConflict,
12272                    "managed session runtime disappeared during context export",
12273                )
12274            })?;
12275        let receipt = pack.receipt();
12276        if !session_record_context_export_binding_is_exact(
12277            expected,
12278            current,
12279            identity,
12280            session,
12281            binding,
12282            &runtime.agent_id,
12283            &current_root,
12284            allow_clean_detachment,
12285        ) || !session_record_context_export_source_is_usable(
12286            current,
12287            &runtime.status,
12288            allow_clean_detachment,
12289        ) || runtime.history.pending.is_some()
12290            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12291            || runtime.history.loaded.as_ref() != Some(loaded)
12292            || receipt.lineage.source_node_id != self.node_id
12293            || receipt.lineage.source_session != *session
12294            || receipt.lineage.source_provider != current.provider
12295            || receipt.source_message_count != loaded.message_count
12296        {
12297            return Err(failure(
12298                NodeFailureCode::SessionRecordConflict,
12299                "managed session source changed before context catalog commit",
12300            ));
12301        }
12302        self.commit_context_pack_durably(&pack)?;
12303        self.context_catalog
12304            .write()
12305            .unwrap_or_else(|poisoned| poisoned.into_inner())
12306            .insert(pack)
12307            .map_err(|_| {
12308                failure(
12309                    NodeFailureCode::ContextPackMaterializationFailed,
12310                    "context pack catalog is full",
12311                )
12312            })
12313    }
12314
12315    async fn context_pack_repository(
12316        &self,
12317        workspace_id: &WorkspaceId,
12318    ) -> Result<ContextPackRepository, NodeFailure> {
12319        let first = self
12320            .context_pack_repository_observation(workspace_id)
12321            .await?;
12322        let second = self
12323            .context_pack_repository_observation(workspace_id)
12324            .await?;
12325        stable_context_pack_repository(first, second)
12326    }
12327
12328    async fn context_pack_repository_observation(
12329        &self,
12330        workspace_id: &WorkspaceId,
12331    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12332        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12333        let head = if git.is_repository {
12334            let root = self.workspace_root(workspace_id)?;
12335            match resolve_base_commit_with_timeout(
12336                &root,
12337                "HEAD",
12338                GIT_COMMAND_TIMEOUT_MS,
12339            )
12340            .await
12341            {
12342                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12343                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12344                    ContextPackRepositoryHead::Unborn
12345                }
12346                Err(_) => {
12347                    return Err(failure(
12348                        NodeFailureCode::ContextPackMaterializationFailed,
12349                        "context pack HEAD observation failed",
12350                    ));
12351                }
12352            }
12353        } else {
12354            ContextPackRepositoryHead::NotRepository
12355        };
12356        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12357        for selected_path in CONTEXT_PACK_SELECTED_FILES {
12358            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12359                failure(
12360                    NodeFailureCode::InvalidRepositoryPath,
12361                    "context pack selected file path is invalid",
12362                )
12363            })?;
12364            match self.read_workspace_file(workspace_id.clone(), path).await {
12365                Ok(WorkspaceFileRead {
12366                    content: WorkspaceFileContent::Utf8 { text, byte_len },
12367                    ..
12368                }) => files.push(ContextPackRepositoryFileSource::utf8(
12369                    selected_path,
12370                    text,
12371                    byte_len,
12372                )),
12373                Ok(WorkspaceFileRead {
12374                    content: WorkspaceFileContent::NonUtf8 { byte_len },
12375                    ..
12376                }) => files.push(ContextPackRepositoryFileSource::skipped(
12377                    selected_path,
12378                    ContextPackSelectedFileSkipReason::NonUtf8,
12379                    Some(byte_len),
12380                )),
12381                Ok(WorkspaceFileRead {
12382                    content: WorkspaceFileContent::TooLarge { .. },
12383                    ..
12384                }) => files.push(ContextPackRepositoryFileSource::skipped(
12385                    selected_path,
12386                    ContextPackSelectedFileSkipReason::TooLarge,
12387                    None,
12388                )),
12389                Err(error) => {
12390                    let reason = if matches!(
12391                        error.code,
12392                        NodeFailureCode::InvalidRepositoryPath
12393                            | NodeFailureCode::RepositoryFileNotRegular
12394                            | NodeFailureCode::RepositoryPathUnsafe
12395                    ) {
12396                        ContextPackSelectedFileSkipReason::Unsafe
12397                    } else {
12398                        ContextPackSelectedFileSkipReason::Unavailable
12399                    };
12400                    files.push(ContextPackRepositoryFileSource::skipped(
12401                        selected_path,
12402                        reason,
12403                        None,
12404                    ));
12405                }
12406            }
12407        }
12408        Ok(ContextPackRepositoryObservation { head, git, files })
12409    }
12410
12411    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12412        let referenced_by_binding = self
12413            .session_bindings
12414            .lock()
12415            .unwrap_or_else(|poisoned| poisoned.into_inner())
12416            .values()
12417            .any(|binding| {
12418                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12419            });
12420        let referenced_by_record = self
12421            .session_records
12422            .lock()
12423            .unwrap_or_else(|poisoned| poisoned.into_inner())
12424            .records
12425            .values()
12426            .any(|record| record.context_id.as_ref() == Some(context_id));
12427        let referenced_by_materialization = self
12428            .materializations
12429            .lock()
12430            .unwrap_or_else(|poisoned| poisoned.into_inner())
12431            .values()
12432            .any(|ownership| {
12433                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12434            });
12435        if referenced_by_binding
12436            || referenced_by_record
12437            || referenced_by_materialization
12438        {
12439            return Err(failure(
12440                NodeFailureCode::ContextPackBusy,
12441                "context pack is still referenced",
12442            ));
12443        }
12444        let mut catalog = self.context_catalog
12445            .write()
12446            .unwrap_or_else(|poisoned| poisoned.into_inner());
12447        if catalog.get(context_id).is_none() {
12448            return Err(failure(
12449                NodeFailureCode::UnknownContextPack,
12450                "context pack is unavailable",
12451            ));
12452        }
12453        self.spawn_idempotency
12454            .lock()
12455            .unwrap_or_else(|poisoned| poisoned.into_inner())
12456            .entries
12457            .retain(|_, entry| !entry.value.references_context(context_id));
12458        let removed = catalog.remove(context_id);
12459        debug_assert!(removed.is_some(), "validated context pack remains present");
12460        Ok(())
12461    }
12462
12463    async fn dispatch_input_bounded(
12464        &self,
12465        address: &SessionAddress,
12466        action: InputAction,
12467    ) -> Result<(), NodeFailure> {
12468        let started_after = self.current_sequence();
12469        self.pty_input_marks
12470            .lock()
12471            .unwrap_or_else(|poisoned| poisoned.into_inner())
12472            .insert(address.session.instance_id, (Instant::now(), false));
12473        self.pty_input_wake.notify_one();
12474        let command_id = self
12475            .dispatch_bounded(
12476                ControlCommand::SendInput {
12477                    instance_id: address.session.instance_id,
12478                    action,
12479                },
12480                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12481            )
12482            .await?;
12483        // A semantic prompt may spend the provider's full readiness budget before
12484        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12485        // but strictly beyond every readiness policy in this node's catalog.
12486        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12487        let mut scan_after = started_after;
12488        let mut accepted_after = None;
12489        loop {
12490            let events = self
12491                .history
12492                .lock()
12493                .unwrap_or_else(|poisoned| poisoned.into_inner())
12494                .events
12495                .iter()
12496                .filter(|envelope| envelope.sequence > scan_after)
12497                .cloned()
12498                .collect::<Vec<_>>();
12499            for envelope in events {
12500                scan_after = scan_after.max(envelope.sequence);
12501                let NodeEvent::Control {
12502                    address: event_address,
12503                    event,
12504                } = envelope.event
12505                else {
12506                    continue;
12507                };
12508                if event_address != *address {
12509                    continue;
12510                }
12511                if event.command_id == Some(command_id) {
12512                    match &event.event {
12513                        ControlEventKind::CommandRejected { message } => {
12514                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12515                        }
12516                        ControlEventKind::InputRequested { .. } => {
12517                            accepted_after = Some(envelope.sequence);
12518                            continue;
12519                        }
12520                        _ => {}
12521                    }
12522                }
12523                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12524                    match &event.event {
12525                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12526                        ControlEventKind::InputFailed { message, .. } => {
12527                            return Err(failure(
12528                                NodeFailureCode::BackendOperationFailed,
12529                                message,
12530                            ));
12531                        }
12532                        _ => {}
12533                    }
12534                }
12535            }
12536            if Instant::now() >= deadline {
12537                return Err(failure(
12538                    NodeFailureCode::BackendBusy,
12539                    if accepted_after.is_some() {
12540                        "input was accepted but did not settle before the bounded deadline"
12541                    } else {
12542                        "input was not accepted before the bounded deadline"
12543                    },
12544                ));
12545            }
12546            sleep(Duration::from_millis(2)).await;
12547        }
12548    }
12549
12550    async fn dispatch_resize_bounded(
12551        &self,
12552        address: &SessionAddress,
12553        size: gate4agent_types::TerminalSize,
12554    ) -> Result<(), NodeFailure> {
12555        let started_after = self.current_sequence();
12556        let command_id = self
12557            .dispatch_bounded(
12558                ControlCommand::Resize {
12559                    instance_id: address.session.instance_id,
12560                    size,
12561                },
12562                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12563            )
12564            .await?;
12565        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12566        let mut scan_after = started_after;
12567        let mut accepted_after = None;
12568        loop {
12569            let events = self
12570                .history
12571                .lock()
12572                .unwrap_or_else(|poisoned| poisoned.into_inner())
12573                .events
12574                .iter()
12575                .filter(|envelope| envelope.sequence > scan_after)
12576                .cloned()
12577                .collect::<Vec<_>>();
12578            for envelope in events {
12579                scan_after = scan_after.max(envelope.sequence);
12580                let NodeEvent::Control {
12581                    address: event_address,
12582                    event,
12583                } = envelope.event
12584                else {
12585                    continue;
12586                };
12587                if event_address != *address {
12588                    continue;
12589                }
12590                if event.command_id == Some(command_id) {
12591                    match &event.event {
12592                        ControlEventKind::CommandRejected { message } => {
12593                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12594                        }
12595                        ControlEventKind::ResizeRequested {
12596                            size: requested_size,
12597                            ..
12598                        } if *requested_size == size => {
12599                            accepted_after = Some(envelope.sequence);
12600                            continue;
12601                        }
12602                        _ => {}
12603                    }
12604                }
12605                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12606                    match &event.event {
12607                        ControlEventKind::Resized { size: settled_size }
12608                            if *settled_size == size =>
12609                        {
12610                            return Ok(());
12611                        }
12612                        ControlEventKind::ResizeFailed { message } => {
12613                            return Err(failure(
12614                                NodeFailureCode::BackendOperationFailed,
12615                                message,
12616                            ));
12617                        }
12618                        _ => {}
12619                    }
12620                }
12621            }
12622            if Instant::now() >= deadline {
12623                return Err(failure(
12624                    NodeFailureCode::BackendBusy,
12625                    if accepted_after.is_some() {
12626                        "resize was accepted but did not settle before the bounded deadline"
12627                    } else {
12628                        "resize was not accepted before the bounded deadline"
12629                    },
12630                ));
12631            }
12632            sleep(Duration::from_millis(2)).await;
12633        }
12634    }
12635
12636    async fn spawn_session(
12637        &self,
12638        workspace_id: WorkspaceId,
12639        provider: AgentId,
12640        mode: SessionMode,
12641        terminal_size: gate4agent_types::TerminalSize,
12642        initial_prompt: Option<String>,
12643    ) -> Result<SessionAddress, NodeFailure> {
12644        if self.managed_worktrees.lock()
12645            .unwrap_or_else(|poisoned| poisoned.into_inner())
12646            .lease_for_workspace(&workspace_id)
12647            .is_some()
12648        {
12649            return Err(failure(
12650                NodeFailureCode::ManagedWorktreeOwnershipConflict,
12651                "managed worktree targets may only be spawned through their lease request",
12652            ));
12653        }
12654        self
12655            .spawn_session_with_deadline(
12656                workspace_id,
12657                provider,
12658                mode,
12659                terminal_size,
12660                initial_prompt,
12661                None,
12662                None,
12663                None,
12664                None,
12665                None,
12666                SpawnRecordPolicy::ProviderIdentityOnly,
12667                None,
12668                &[],
12669                None,
12670                // `NodeRequest::Spawn` (this method's only caller) carries no
12671                // approval-level selection of its own -- see
12672                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
12673                // the one operator-wire path that can choose something other
12674                // than the axis default.
12675                ApprovalLevel::default(),
12676                None,
12677                None,
12678            )
12679            .await
12680            .map(|(session, _runtime_policy)| session)
12681    }
12682
12683    async fn spawn_session_with_deadline(
12684        &self,
12685        workspace_id: WorkspaceId,
12686        provider: AgentId,
12687        mode: SessionMode,
12688        terminal_size: gate4agent_types::TerminalSize,
12689        initial_prompt: Option<String>,
12690        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12691        bundle: Option<ResolvedBundleReceipt>,
12692        context: Option<ResolvedContextPackReceipt>,
12693        managed_authority: Option<ManagedWorktreeLeaseId>,
12694        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12695        record_policy: SpawnRecordPolicy,
12696        deadline: Option<Instant>,
12697        required_capabilities: &[ProviderRuntimeCapability],
12698        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12699        approval_level: ApprovalLevel,
12700        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12701        // Opaque dig2browser station profile id (ids only). Feature
12702        // `dig2-station-probe`: after probe at resolve, spawn takes an
12703        // exclusive node-local lease; cleared on session end. Never
12704        // ImportSession / cookies on C2. Feature off: ignored (stub echo
12705        // already happened on the receipt path).
12706        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12707    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12708        let reserved_lease = self.managed_worktrees.lock()
12709            .unwrap_or_else(|poisoned| poisoned.into_inner())
12710            .lease_for_workspace(&workspace_id);
12711        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12712            (Some(expected), Some(provided)) if expected == provided => {}
12713            (None, None) => {}
12714            _ => {
12715                return Err(failure(
12716                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
12717                    "spawn lacks exact managed worktree lease authority",
12718                ));
12719            }
12720        }
12721        // Refuse concurrent distinct environment profiles (incl. ProviderHome
12722        // bindings) on the same workspace cwd. Parallel path: managed worktree
12723        // spawn so each profile gets its own workspace_id.
12724        self.ensure_workspace_environment_profile_compatible(
12725            &workspace_id,
12726            environment_profile.as_ref(),
12727        )?;
12728        let runtime_requirement = match (mode, initial_prompt.is_some()) {
12729            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12730            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12731            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12732            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12733        };
12734        // This session's own transport -- computed once, here, from `mode`,
12735        // and reused both to admit spawn (below) and, further down, to
12736        // compute the final `runtime_policy` this session actually operates
12737        // under (`provider_runtime::policy_for_transport`). Registration
12738        // reuses this exact value rather than recomputing it a second time.
12739        let transport = match mode {
12740            SessionMode::Pty => TransportKind::Pty,
12741            SessionMode::Inline => TransportKind::Pipe,
12742            SessionMode::Acp => TransportKind::Acp,
12743        };
12744        let pipe_transport = self.provider_supports_pipe_transport(&provider);
12745        let acp_transport = self.provider_supports_acp_transport(&provider);
12746        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12747            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12748                NodeFailureCode::UnsupportedSpawnCapability,
12749                "pre-admitted provider runtime no longer satisfies spawn requirements",
12750            ))?;
12751            runtime_policy
12752        } else if let Some(deadline) = deadline {
12753            let remaining = spawn_deadline_remaining(deadline)?;
12754            timeout(
12755                remaining,
12756                self.admit_provider_runtime(&provider, runtime_requirement),
12757            )
12758            .await
12759            .map_err(|_| {
12760                failure(
12761                    NodeFailureCode::SpawnDeadlineExceeded,
12762                    "provider admission exceeded the spawn deadline",
12763                )
12764            })??
12765        } else {
12766            self.admit_provider_runtime(&provider, runtime_requirement).await?
12767        };
12768        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12769        // The single point where this session's `runtime_policy` becomes
12770        // transport-correct: `admit_provider_runtime` (and the pre-admitted
12771        // branch above) only ever compute a PTY-terminal-verification-shaped
12772        // value, sound outright only for `TransportKind::Pty` (Pipe still
12773        // relies on that same value downstream -- see `policy_for_transport`'s
12774        // own doc for why). `policy_for_transport` is the explicit,
12775        // exhaustive match Item 1 asks for -- this is the ONE place its
12776        // output becomes the session's `runtime_policy`, so every later
12777        // reader (`ControlCommand::Start`, `session_bindings`, and every
12778        // engine gate that reads `state.runtime_policy`) sees the same,
12779        // correctly-computed value.
12780        let runtime_policy =
12781            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12782        if required_capabilities
12783            .iter()
12784            .any(|capability| !runtime_policy.admits(*capability))
12785        {
12786            return Err(failure(
12787                NodeFailureCode::UnsupportedSpawnCapability,
12788                "provider runtime does not admit a required spawn capability",
12789            ));
12790        }
12791        if let Some(deadline) = deadline {
12792            spawn_deadline_remaining(deadline)?;
12793        }
12794        self.ensure_binding_capacity()?;
12795        let mut working_directory = self.workspace_root(&workspace_id)?;
12796        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12797        let session = SessionKey {
12798            instance_id,
12799            generation: SessionGeneration(1),
12800        };
12801        let address = SessionAddress {
12802            workspace_id: workspace_id.clone(),
12803            session,
12804        };
12805        // This is the request-accepted checkpoint: admission (managed
12806        // worktree authority, runtime capability, dispatch deadline) already
12807        // passed and a concrete session identity is minted. It is a distinct
12808        // state from the process actually running (`ControlEventKind::Running`,
12809        // logged in `publish_control`) or the terminal outcome (`Exited`/
12810        // `Failed`, logged there too) -- this line exists so that gap is no
12811        // longer silent.
12812        tracing::info!(
12813            node_id = %self.node_id,
12814            workspace_id = %workspace_id,
12815            provider = %provider,
12816            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12817            session = ?address.session,
12818            "session spawn request accepted",
12819        );
12820        let mut prepared_spawn = SpawnPreparer::prepare(
12821            self,
12822            SpawnPrepareRequest {
12823                instance_id,
12824                address: &address,
12825                provider: &provider,
12826                mode,
12827                environment_profile: environment_profile.as_ref(),
12828                bundle: bundle.as_ref(),
12829                context: context.as_ref(),
12830                managed_authority: managed_authority.as_ref(),
12831                harness_mcp,
12832                approval_level,
12833                network_allowlist: network_allowlist.as_ref(),
12834                browser_profile_id,
12835            },
12836        )?;
12837        let materialization_id = prepared_spawn.materialization_id.take();
12838        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12839        let record_id = self.bind_spawn_session_with_materialization(
12840            &address,
12841            provider.clone(),
12842            mode,
12843            runtime_policy,
12844            record_policy,
12845            environment_profile,
12846            bundle,
12847            context,
12848            materialization_id,
12849        )?;
12850        prepared_spawn.retain_session_guards();
12851        // `transport` was already computed above, alongside this session's
12852        // `runtime_policy`; reused here for `Register` rather than derived a
12853        // second time.
12854        let session_provider = provider.clone();
12855        let agent_id = provider;
12856        let register_command_id = match self.dispatch_bounded(
12857            ControlCommand::Register {
12858                instance_id,
12859                agent_id,
12860                transport,
12861            },
12862            dispatch_timeout,
12863        )
12864        .await {
12865            Ok(command_id) => command_id,
12866            Err(error) => {
12867                if let Some(binding) = self.remove_binding(&address) {
12868                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12869                }
12870                if let Some(record_id) = record_id.as_ref() {
12871                    self.discard_record(record_id)?;
12872                }
12873                return Err(spawn_dispatch_error(error, deadline));
12874            }
12875        };
12876        let start_timeout = match spawn_dispatch_timeout(deadline) {
12877            Ok(timeout) => timeout,
12878            Err(error) => {
12879                let recovery = self
12880                    .rollback_spawn(
12881                        &address,
12882                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12883                    )
12884                    .await;
12885                return match recovery {
12886                    Ok(()) => {
12887                        if let Some(record_id) = record_id.as_ref() {
12888                            self.discard_record(record_id)?;
12889                        }
12890                        Err(error)
12891                    }
12892                    Err(recovery_error) => {
12893                        if let Some(record_id) = record_id.as_ref() {
12894                            self.mark_record_error(
12895                                record_id,
12896                                "spawn deadline elapsed and registration recovery failed",
12897                            )?;
12898                        }
12899                        Err(recovery_error)
12900                    }
12901                };
12902            }
12903        };
12904        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12905            if let Err(recovery_error) = self
12906                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12907                .await
12908            {
12909                return Err(recovery_error);
12910            }
12911            if let Some(record_id) = record_id.as_ref() {
12912                self.discard_record(record_id)?;
12913            }
12914            return Err(start_error);
12915        }
12916        // A session with its own m4a account runs in its own folder inside the workspace.
12917        if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
12918            working_directory = dir.to_string_lossy().into_owned();
12919        }
12920        let start_result = self
12921            .dispatch_bounded(
12922                ControlCommand::Start {
12923                    instance_id,
12924                    runtime_policy,
12925                    request: StartRequest {
12926                        working_directory,
12927                        terminal_size,
12928                        initial_prompt,
12929                        session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
12930                        approval_level,
12931                    },
12932                },
12933                start_timeout,
12934            )
12935            .await;
12936        let start_command_id = match start_result {
12937            Ok(command_id) => command_id,
12938            Err(start_error) => {
12939                let start_error = spawn_dispatch_error(start_error, deadline);
12940                let recovery = self
12941                    .rollback_spawn(
12942                        &address,
12943                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12944                    )
12945                    .await;
12946                return match recovery {
12947                    Ok(()) => {
12948                        if let Some(record_id) = record_id.as_ref() {
12949                            self.discard_record(record_id)?;
12950                        }
12951                        Err(start_error)
12952                    }
12953                    Err(recovery_error) => {
12954                        let diagnostic = format!(
12955                            "start failed and registration recovery failed: {}",
12956                            recovery_error.message,
12957                        );
12958                        if let Some(record_id) = record_id.as_ref() {
12959                            self.mark_record_error(record_id, &diagnostic)?;
12960                        }
12961                        Err(failure(recovery_error.code, &diagnostic))
12962                    }
12963                };
12964            }
12965        };
12966
12967        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12968        loop {
12969            // `Register`/`Start` only fail synchronously for a port-level
12970            // ingress problem (see `dispatch_bounded`); the kernel refusing
12971            // either command's content -- an unsupported transport, an
12972            // unknown agent -- lands one drive-loop tick later, and no
12973            // session ever appears in `self.handle.snapshot()` for it to
12974            // notice below. Check for that named reason before falling
12975            // through to the blind presence poll, so a rejected spawn
12976            // reports why instead of just timing out.
12977            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12978                .or_else(|| self.take_rejected_command_reason(start_command_id))
12979            {
12980                self.rollback_spawn(
12981                    &address,
12982                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12983                ).await.map_err(|error| {
12984                        failure(
12985                            error.code,
12986                            &format!(
12987                                "spawn was rejected ({}) and registration recovery failed: {}",
12988                                reason.message, error.message
12989                            ),
12990                        )
12991                    })?;
12992                if let Some(record_id) = record_id.as_ref() {
12993                    self.discard_record(record_id)?;
12994                }
12995                let code = if reason.unsupported_transport {
12996                    NodeFailureCode::UnsupportedTransport
12997                } else {
12998                    NodeFailureCode::BackendOperationFailed
12999                };
13000                return Err(failure(code, &reason.message));
13001            }
13002            if let Some(current) = self
13003                .handle
13004                .snapshot()
13005                .sessions
13006                .iter()
13007                .find(|current| current.instance_id == instance_id)
13008            {
13009                if current.generation != session.generation {
13010                    let divergence = failure(
13011                        NodeFailureCode::StaleGeneration,
13012                        "spawn generation diverged",
13013                    );
13014                    return match self
13015                        .rollback_spawn(
13016                            &address,
13017                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13018                        )
13019                        .await
13020                    {
13021                        Ok(()) => {
13022                            if let Some(record_id) = record_id.as_ref() {
13023                                self.discard_record(record_id)?;
13024                            }
13025                            Err(divergence)
13026                        }
13027                        Err(recovery_error) => {
13028                            if let Some(record_id) = record_id.as_ref() {
13029                                self.mark_record_error(
13030                                    record_id,
13031                                    "spawn generation diverged and recovery failed",
13032                                )?;
13033                            }
13034                            Err(recovery_error)
13035                        }
13036                    };
13037                }
13038                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13039                    prepared_spawn.retain_launch_guards();
13040                    return Ok((address, runtime_policy));
13041                }
13042            }
13043            if Instant::now() >= commit_deadline {
13044                self.rollback_spawn(
13045                    &address,
13046                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13047                ).await.map_err(|error| {
13048                        failure(
13049                            error.code,
13050                            &format!(
13051                                "start did not commit and registration recovery failed: {}",
13052                                error.message
13053                            ),
13054                        )
13055                    })?;
13056                if let Some(record_id) = record_id.as_ref() {
13057                    self.discard_record(record_id)?;
13058                }
13059                return Err(failure(
13060                    if deadline.is_some() {
13061                        NodeFailureCode::SpawnDeadlineExceeded
13062                    } else {
13063                        NodeFailureCode::BackendBusy
13064                    },
13065                    "start did not commit before the bounded deadline; registration was removed",
13066                ));
13067            }
13068            sleep(Duration::from_millis(2)).await;
13069        }
13070    }
13071
13072    fn effective_spawn_runtime_policy(
13073        &self,
13074        runtime_policy: ProviderRuntimePolicy,
13075    ) -> ProviderRuntimePolicy {
13076        #[cfg(feature = "fixture")]
13077        let runtime_policy = if self.fixture_semantic_hook_policy {
13078            ProviderRuntimePolicy::new(true, true, true, true, false, true)
13079                .expect("monitoring Hook fixture policy is internally valid")
13080        } else {
13081            runtime_policy
13082        };
13083        runtime_policy
13084    }
13085
13086    fn verify_harness_mcp_before_start(
13087        &self,
13088        prepared: Option<&PreparedHarnessMcpSpawn>,
13089    ) -> Result<(), NodeFailure> {
13090        prepared
13091            .map(PreparedHarnessMcpSpawn::verify_helper_program)
13092            .transpose()
13093            .map(|_| ())
13094            .map_err(harness_mcp_failure)
13095    }
13096
13097    async fn rollback_spawn(
13098        &self,
13099        address: &SessionAddress,
13100        commit_timeout: Duration,
13101    ) -> Result<(), NodeFailure> {
13102        self.dispatch_bounded(
13103            ControlCommand::Remove {
13104                instance_id: address.session.instance_id,
13105            },
13106            commit_timeout,
13107        )
13108        .await?;
13109        self.wait_until_removed(address, commit_timeout).await?;
13110        if let Some(binding) = self.remove_binding(address) {
13111            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13112        }
13113        Ok(())
13114    }
13115
13116    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13117        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13118        self.dispatch_bounded(
13119            ControlCommand::Remove {
13120                instance_id: address.session.instance_id,
13121            },
13122            commit_timeout,
13123        )
13124            .await?;
13125        self.wait_until_removed(address, commit_timeout).await?;
13126        let removed = self.remove_binding(address);
13127        if let Some(binding) = removed.as_ref() {
13128            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13129        }
13130        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13131            let snapshot = self.managed_worktrees.lock()
13132                .unwrap_or_else(|poisoned| poisoned.into_inner())
13133                .release_session(
13134                    &lease_id,
13135                    self.incarnation_id,
13136                    address.session.instance_id,
13137                    address.session.generation,
13138                    unix_time_ms(),
13139                );
13140            if self.persist_state().is_err() {
13141                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13142            }
13143            if let Some(snapshot) = snapshot {
13144                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13145            }
13146            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13147        }
13148        Ok(())
13149    }
13150
13151    /// Polls until `address`'s session leaves the engine's own session map,
13152    /// or the bounded deadline expires.
13153    ///
13154    /// A `Remove` sent while the session is not yet in a terminal status
13155    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
13156    /// once by the engine (`SessionStatus::allows_remove`) and
13157    /// then discarded -- nothing re-issues it on its own, so the removal
13158    /// that would satisfy this wait never arrives and the caller was
13159    /// guaranteed to time out even though the session settles microseconds
13160    /// later. So this re-dispatches on every poll tick: a session that
13161    /// settles mid-wait -- the common case right after a `Stop` -- still
13162    /// gets removed inside the same bounded deadline.
13163    ///
13164    /// It re-dispatches only once the status will ACCEPT the command. A
13165    /// rejected re-dispatch is not the harmless no-op this loop first
13166    /// assumed: every rejection publishes a `ControlEventKind::Command
13167    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
13168    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
13169    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
13170    /// out to the harness and every attached client. Gated, the same
13171    /// teardown sends exactly one `Remove`, at the first tick the engine
13172    /// can act on it.
13173    ///
13174    /// The status is read from the snapshot this loop already takes, so
13175    /// the gate costs nothing extra. It races -- the status can move
13176    /// between the read and the dispatch -- and that is fine: a rare lost
13177    /// race is one rejection, and the next tick retries.
13178    ///
13179    /// Re-dispatch is separately rate-limited to
13180    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
13181    /// leaves a second burst behind it. Once the session settles, every
13182    /// 2ms tick sent another `Remove` while the accepted one was still in
13183    /// flight, and each of those landed on an instance the engine had
13184    /// already dropped -- `agent instance … is not registered`, observed
13185    /// live as a tail of rejections immediately after the `session
13186    /// removed` line. The presence poll stays at 2ms so the wait still
13187    /// exits promptly; only the sending slows down.
13188    ///
13189    /// Rate-limited rather than once-only on purpose: the status gate
13190    /// covers the status precondition, but `Gate4AgentEngine::remove` has
13191    /// others (a pending operation, an in-flight capability probe or
13192    /// history request) that clear on their own. A single shot would turn
13193    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
13194    /// get their retries, just twenty of them across the deadline instead
13195    /// of a thousand.
13196    async fn wait_until_removed(
13197        &self,
13198        address: &SessionAddress,
13199        commit_timeout: Duration,
13200    ) -> Result<(), NodeFailure> {
13201        let deadline = Instant::now() + commit_timeout;
13202        let mut next_dispatch = Instant::now();
13203        loop {
13204            let snapshot = self.handle.snapshot();
13205            let status = snapshot
13206                .sessions
13207                .iter()
13208                .find(|session| session.instance_id == address.session.instance_id)
13209                .map(|session| session.status.clone());
13210            let Some(status) = status else {
13211                return Ok(());
13212            };
13213            let now = Instant::now();
13214            if now >= deadline {
13215                return Err(failure(
13216                    NodeFailureCode::BackendBusy,
13217                    "remove did not commit before the bounded deadline; session binding was retained",
13218                ));
13219            }
13220            if status.allows_remove() && now >= next_dispatch {
13221                let _ = self.dispatch(ControlCommand::Remove {
13222                    instance_id: address.session.instance_id,
13223                });
13224                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13225            }
13226            sleep(Duration::from_millis(2)).await;
13227        }
13228    }
13229
13230    fn resync(&self, after_sequence: u64) -> NodeResponse {
13231        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13232        let event_sequence = history.last_sequence;
13233        let oldest_available_sequence = history.replay_floor_sequence;
13234        let events = history
13235            .events
13236            .iter()
13237            .filter(|event| event.sequence > after_sequence)
13238            .cloned()
13239            .collect();
13240        drop(history);
13241        NodeResponse::Resync {
13242            event_sequence,
13243            oldest_available_sequence,
13244            snapshot: self.snapshot(),
13245            events,
13246        }
13247    }
13248}
13249
13250/// Coarse label for one `AgentStreamChunkKindV1`, used only by
13251/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
13252/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
13253/// "name what crossed this boundary" reasoning, one layer upstream of it).
13254fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13255    match kind {
13256        AgentStreamChunkKindV1::Text { .. } => "text",
13257        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13258        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13259        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13260        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13261        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13262        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13263    }
13264}
13265
13266/// Serves this wire on the local endpoint, and -- when `call_home` names a
13267/// relay -- over an outbound connection to it as well.
13268///
13269/// The two live in one future rather than a fourth arm of `run`'s own
13270/// `select!` because they are one concern: this is where the node's wire
13271/// is served, and the direction a given connection was opened in does not
13272/// change what happens on it. It also means the existing graceful drain
13273/// below covers both without being duplicated.
13274///
13275/// Either side finishing ends the pair, which is correct: the listener
13276/// only returns on shutdown or a bind failure, and `call_home_loop` only
13277/// returns on shutdown -- a failed relay connection is retried inside it,
13278/// never propagated out.
13279async fn accept_connections(
13280    endpoint: &str,
13281    call_home: Option<std::net::SocketAddr>,
13282    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13283    shared: Arc<NodeShared>,
13284) -> Result<(), NodeServerError> {
13285    let mut connections = JoinSet::new();
13286    let result = match call_home {
13287        None => accept_connections_inner(endpoint, shared, &mut connections).await,
13288        Some(relay) => {
13289            let listener =
13290                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13291            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13292            tokio::pin!(listener);
13293            tokio::pin!(dialler);
13294            tokio::select! {
13295                result = &mut listener => result,
13296                result = &mut dialler => result,
13297            }
13298        }
13299    };
13300    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13301    while !connections.is_empty() {
13302        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13303        if remaining.is_zero()
13304            || timeout(remaining, connections.join_next()).await.is_err()
13305        {
13306            break;
13307        }
13308    }
13309    connections.abort_all();
13310    while connections.join_next().await.is_some() {}
13311    result
13312}
13313
13314/// Backoff ladder for a relay that will not answer, capped rather than
13315/// unbounded: a node whose relay is down is not broken and should keep
13316/// trying forever, but it should not spend a core doing it.
13317const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13318
13319/// Dials the relay, serves this wire over the connection, and does it
13320/// again when the connection ends.
13321///
13322/// The node is the SERVER on the socket it just opened. That inversion is
13323/// the whole point: `serve_connection` is the same function the local
13324/// listener feeds, and it neither knows nor cares which end placed the
13325/// call. The one thing this direction adds is the preface -- the relay
13326/// accepted a socket and cannot know whose it is, so the node says its
13327/// name before the handshake it would otherwise begin with.
13328///
13329/// Never returns an error. A relay that is down, refusing, or speaking a
13330/// different protocol is a condition to retry, not a reason to take the
13331/// node's local endpoint down with it; the only way out is shutdown.
13332async fn call_home_loop(
13333    relay: std::net::SocketAddr,
13334    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13335    shared: Arc<NodeShared>,
13336) -> Result<(), NodeServerError> {
13337    let mut failures = 0_usize;
13338    loop {
13339        if shared.shutdown.load(Ordering::Acquire) {
13340            return Ok(());
13341        }
13342        let permit = tokio::select! {
13343            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13344                permit.map_err(|_| NodeServerError::PreauthClosed)?
13345            }
13346            _ = shared.shutdown_notify.notified() => return Ok(()),
13347        };
13348        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13349            Ok(()) => {
13350                // A clean end is the relay hanging up, which it does on
13351                // its own restart. Reconnect immediately rather than
13352                // serving the backoff a peer that is plainly there.
13353                failures = 0;
13354                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13355            }
13356            Err(error) => {
13357                failures = failures.saturating_add(1);
13358                tracing::warn!(
13359                    node_id = %shared.node_id,
13360                    %relay,
13361                    cause = %error,
13362                    attempt = failures,
13363                    "call-home connection failed",
13364                );
13365                let backoff = call_home_backoff(failures);
13366                tokio::select! {
13367                    _ = sleep(backoff) => {}
13368                    _ = shared.shutdown_notify.notified() => return Ok(()),
13369                }
13370            }
13371        }
13372    }
13373}
13374
13375/// Idle time before the first keepalive probe on a call-home socket.
13376const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13377/// Interval between keepalive probes once the link has gone quiet.
13378const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13379
13380fn call_home_keepalive() -> socket2::TcpKeepalive {
13381    let keepalive = socket2::TcpKeepalive::new()
13382        .with_time(CALL_HOME_KEEPALIVE_IDLE)
13383        .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13384    #[cfg(not(windows))]
13385    let keepalive = keepalive.with_retries(3);
13386    keepalive
13387}
13388
13389fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13390    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13391}
13392
13393/// Same keepalive profile for any node->relay socket (restream uplink).
13394pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13395    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13396}
13397
13398/// Backoff for the `attempt`-th consecutive failure (1-based), capped.
13399fn call_home_backoff(attempt: usize) -> Duration {
13400    Duration::from_millis(
13401        CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13402    )
13403}
13404
13405
13406/// Dial the call-home relay. Tunnel addresses go through the in-process
13407/// WireGuard dialer when the userspace session is up.
13408async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13409    #[cfg(feature = "wireguard")]
13410    {
13411        let std_stream = tokio::task::spawn_blocking(move || {
13412            gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13413        })
13414        .await
13415        .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13416        .map_err(NodeServerError::CallHomeTransport)?;
13417        std_stream
13418            .set_nonblocking(true)
13419            .map_err(NodeServerError::CallHomeTransport)?;
13420        return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13421    }
13422    #[cfg(not(feature = "wireguard"))]
13423    {
13424        tokio::net::TcpStream::connect(relay)
13425            .await
13426            .map_err(NodeServerError::CallHomeTransport)
13427    }
13428}
13429
13430/// One call-home attempt: connect, name this node, then serve.
13431async fn dial_relay_and_serve(
13432    relay: std::net::SocketAddr,
13433    identity: Option<&hatchery_enroll::NodeIdentity>,
13434    shared: Arc<NodeShared>,
13435    preauth_permit: OwnedSemaphorePermit,
13436) -> Result<(), NodeServerError> {
13437    let mut stream = dial_call_home(relay).await?;
13438    // Nagle off for the same reason the rest of this wire wants it off:
13439    // the traffic is small request/response frames, and coalescing them
13440    // adds latency to every one.
13441    let _ = stream.set_nodelay(true);
13442    // A relay that vanishes without a FIN/RST reaching us (tunnel drop,
13443    // relay-side socket kill whose RST was lost) would otherwise leave this
13444    // socket ESTABLISHED forever: the node mostly reads, so nothing ever
13445    // fails and the reconnect loop never runs. Kernel keepalive turns that
13446    // half-open link into a read error within a bounded time.
13447    if let Err(error) = enable_call_home_keepalive(&stream) {
13448        tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13449    }
13450    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13451        .await
13452        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13453    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13454    let mut session_token = None;
13455    if let Some(identity) = identity {
13456        // An enrolled relay answers the announce with a signed-call-home challenge; a
13457        // token-mode relay sends its wire hello instead. Peek to tell them apart
13458        // without consuming either.
13459        let mut magic = [0_u8; 4];
13460        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13461            .await
13462            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13463            .map_err(NodeServerError::CallHomeTransport)?;
13464        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13465            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13466            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13467                .await
13468                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13469                .map_err(NodeServerError::CallHomeTransport)?;
13470            let (answer, token) = identity
13471                .answer_challenge(shared.node_id.as_str(), &frame)
13472                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13473            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13474            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13475            session_token = Some(token);
13476        }
13477    }
13478    serve_connection(stream, shared, session_token, preauth_permit).await
13479}
13480
13481async fn accept_connections_inner(
13482    endpoint: &str,
13483    shared: Arc<NodeShared>,
13484    connections: &mut JoinSet<Result<(), NodeServerError>>,
13485) -> Result<(), NodeServerError> {
13486    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13487    loop {
13488        while connections.try_join_next().is_some() {}
13489        if shared.shutdown.load(Ordering::Acquire) {
13490            return Ok(());
13491        }
13492        let preauth_permit = tokio::select! {
13493            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13494                permit.map_err(|_| NodeServerError::PreauthClosed)?
13495            }
13496            _ = shared.shutdown_notify.notified() => return Ok(()),
13497        };
13498        let server = tokio::select! {
13499            result = listener.accept() => result?,
13500            _ = shared.shutdown_notify.notified() => return Ok(()),
13501        };
13502        let connection_shared = Arc::clone(&shared);
13503        connections.spawn(async move {
13504            serve_connection(server, connection_shared, None, preauth_permit).await
13505        });
13506        if shared.shutdown.load(Ordering::Acquire) {
13507            return Ok(());
13508        }
13509    }
13510}
13511
13512/// Serves one connection, whichever end opened it.
13513///
13514/// Generic over the stream rather than typed to `LocalServerStream`
13515/// because a call-home connection arrives on a `TcpStream` the node itself
13516/// dialled. Nothing in the body ever depended on the concrete type -- it
13517/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13518/// the whole of what welding "who dials" to "who serves" was costing.
13519async fn serve_connection<S>(
13520    mut pipe: S,
13521    shared: Arc<NodeShared>,
13522    session_token: Option<String>,
13523    preauth_permit: OwnedSemaphorePermit,
13524) -> Result<(), NodeServerError>
13525where
13526    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13527{
13528    // A signed call-home connection authenticates with its own per-connection token.
13529    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13530    let hello_frame = timeout(
13531        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13532        read_json_frame_limited_body_timeout(
13533            &mut pipe,
13534            MAX_NODE_HELLO_FRAME_BYTES,
13535            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13536        ),
13537    )
13538    .await
13539    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13540    let ClientFrame::Hello(hello) = hello_frame else {
13541        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13542    };
13543    if hello.build_stamp != BUILD_STAMP {
13544        return Err(NodeServerError::Handshake(format!(
13545            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13546            hello.build_stamp,
13547        )));
13548    }
13549    let compatibility = match hello.compatibility.as_ref() {
13550        Some(offer) => {
13551            let mut selected = node_compatibility_support(&shared)?
13552                .negotiate(offer)
13553                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13554            project_negotiated_provider_ids(&mut selected);
13555            Some(selected)
13556        }
13557        None => None,
13558    };
13559    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13560    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13561        (Some(offer), Some(selected)) => negotiated_auth_proof(
13562            auth_token,
13563            AuthDirection::Server,
13564            hello.role,
13565            &hello.client_nonce,
13566            &server_nonce,
13567            offer,
13568            selected,
13569        ),
13570        (None, None) => auth_proof(
13571            auth_token,
13572            AuthDirection::Server,
13573            hello.role,
13574            &hello.client_nonce,
13575            &server_nonce,
13576        ),
13577        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13578    }
13579    .map_err(NodeServerError::Authentication)?;
13580    write_json_frame_limited(
13581        &mut pipe,
13582        &ServerFrame::Challenge(ServerChallenge {
13583            build_stamp: BUILD_STAMP.to_owned(),
13584            server_nonce,
13585            server_proof,
13586            compatibility: compatibility.clone(),
13587        }),
13588        MAX_NODE_HELLO_FRAME_BYTES,
13589    )
13590    .await?;
13591    let authentication = timeout(
13592        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13593        read_json_frame_limited_body_timeout(
13594            &mut pipe,
13595            MAX_NODE_HELLO_FRAME_BYTES,
13596            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13597        ),
13598    )
13599    .await
13600    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13601    let ClientFrame::Authenticate(authentication) = authentication else {
13602        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13603    };
13604    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13605        (Some(offer), Some(selected)) => negotiated_auth_proof(
13606            auth_token,
13607            AuthDirection::Client,
13608            hello.role,
13609            &hello.client_nonce,
13610            &server_nonce,
13611            offer,
13612            selected,
13613        ),
13614        (None, None) => auth_proof(
13615            auth_token,
13616            AuthDirection::Client,
13617            hello.role,
13618            &hello.client_nonce,
13619            &server_nonce,
13620        ),
13621        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13622    }
13623    .map_err(NodeServerError::Authentication)?;
13624    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13625        return Err(NodeServerError::Handshake("access denied".to_owned()));
13626    }
13627    let selected_capabilities = compatibility
13628        .as_ref()
13629        .map(|selected| selected.capabilities.clone())
13630        .unwrap_or_default();
13631    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13632        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13633    });
13634    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13635        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13636    });
13637    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13638        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13639    });
13640    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13641        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13642    });
13643    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13644        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13645    });
13646    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13647        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13648    }) && selected_capabilities.iter().any(|capability| {
13649        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13650    });
13651    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13652        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13653    });
13654    let include_session_bundles = selected_capabilities.iter().any(|capability| {
13655        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13656    });
13657    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13658        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13659    });
13660    let include_agent_progress = selected_capabilities.iter().any(|capability| {
13661        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13662    });
13663    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13664        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13665    });
13666    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13667        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13668    });
13669    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13670        .try_acquire_owned()
13671        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13672    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13673    let _release_guard = ControllerReleaseGuard {
13674        shared: Arc::clone(&shared),
13675        connection_id,
13676    };
13677    drop(preauth_permit);
13678    let _authenticated_permit = authenticated_permit;
13679    let mut event_rx = shared.event_tx.subscribe();
13680    let mut terminal_event_rx = terminal_event_subscription(
13681        &shared,
13682        include_terminal_frame_events,
13683    );
13684    let mut agent_stream_event_rx = agent_stream_event_subscription(
13685        &shared,
13686        include_agent_stream_events,
13687    );
13688    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13689        &shared,
13690        include_harness_mcp_proxy,
13691    );
13692    write_json_frame(
13693        &mut pipe,
13694        &ServerFrame::Hello(NodeHello {
13695            build_stamp: BUILD_STAMP.to_owned(),
13696            incarnation_id: shared.incarnation_id,
13697            connection_id,
13698            role: hello.role,
13699            event_sequence: shared.current_sequence(),
13700            controller: shared.controller_state(),
13701            snapshot: snapshot_for_wire(
13702                &shared,
13703                include_provider_runtime_status,
13704                include_open_provider_ids,
13705                include_spawn_profiles,
13706                include_managed_worktrees,
13707                include_child_environment_profiles,
13708                include_session_bundles,
13709                include_history_context_packs,
13710                include_agent_progress,
13711                include_session_task_correlation,
13712            ),
13713            compatibility,
13714        }),
13715    )
13716    .await?;
13717    let (mut reader, mut writer) = tokio::io::split(pipe);
13718    let (frame_tx, mut frame_rx) = mpsc::channel(16);
13719    let reader_task = tokio::spawn(async move {
13720        loop {
13721            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13722                &mut reader,
13723                MAX_NODE_CLIENT_FRAME_BYTES,
13724                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13725            )
13726            .await;
13727            let terminal = frame.is_err();
13728            if frame_tx.send(frame).await.is_err() || terminal {
13729                break;
13730            }
13731        }
13732    });
13733    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13734    let mut pending_events = Vec::new();
13735    let mut resync_required = false;
13736    let mut discard_events_through = 0_u64;
13737    loop {
13738        // One tick of the serve loop, whether or not it had anything to do.
13739        // Against `connection_events_sent` this separates "busy" from
13740        // "spinning": a loop turning over thousands of times a second while
13741        // sending nothing is not serving, it is burning a core.
13742        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13743        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13744            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13745        for _ in 0..durable_drain_budget {
13746            match event_rx.try_recv() {
13747                Ok(event) => queue_connection_event(
13748                    &mut pending_events,
13749                    event,
13750                    discard_events_through,
13751                ),
13752                Err(broadcast::error::TryRecvError::Empty) => break,
13753                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13754                    resync_required = true;
13755                    break;
13756                }
13757                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13758            }
13759        }
13760        if let Some(receiver) = terminal_event_rx.as_mut() {
13761            match receiver.try_recv() {
13762                Ok(events) => queue_connection_event_batch(
13763                    &mut pending_events,
13764                    &events,
13765                    discard_events_through,
13766                ),
13767                Err(broadcast::error::TryRecvError::Empty) => {}
13768                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13769                    resync_required = true;
13770                }
13771                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13772            }
13773        }
13774        if let Some(receiver) = agent_stream_event_rx.as_mut() {
13775            // Loops up to the same remaining-room budget the durable
13776            // `event_rx` drain above uses, rather than one `try_recv` per
13777            // tick: each successful receive here is itself only ONE
13778            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
13779            // sends `vec![envelope]`, never more), so a single-shot drain
13780            // caps this connection's agent-stream throughput at one chunk
13781            // per outer-loop tick regardless of how many are already queued
13782            // -- exactly the kind of self-inflicted stall this crate's own
13783            // durable-event budget exists to avoid.
13784            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13785                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13786            for _ in 0..agent_stream_drain_budget {
13787                match receiver.try_recv() {
13788                    Ok(events) => queue_connection_event_batch(
13789                        &mut pending_events,
13790                        &events,
13791                        discard_events_through,
13792                    ),
13793                    Err(broadcast::error::TryRecvError::Empty) => break,
13794                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13795                        tracing::warn!(
13796                            connection_id,
13797                            dropped,
13798                            "agent stream event subscription lagged; forcing a resync",
13799                        );
13800                        resync_required = true;
13801                        break;
13802                    }
13803                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13804                }
13805            }
13806        }
13807        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13808            // Mirrors the agent-stream drain immediately above: each
13809            // successful receive is one single-envelope batch
13810            // (`publish_transient` always sends `vec![envelope]`), so this
13811            // loops up to the same remaining-room budget rather than
13812            // stopping after one `try_recv`.
13813            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13814                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13815            for _ in 0..harness_mcp_proxy_drain_budget {
13816                match receiver.try_recv() {
13817                    Ok(events) => queue_connection_event_batch(
13818                        &mut pending_events,
13819                        &events,
13820                        discard_events_through,
13821                    ),
13822                    Err(broadcast::error::TryRecvError::Empty) => break,
13823                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13824                        tracing::warn!(
13825                            connection_id,
13826                            dropped,
13827                            "harness MCP proxy event subscription lagged; forcing a resync",
13828                        );
13829                        resync_required = true;
13830                        break;
13831                    }
13832                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13833                }
13834            }
13835        }
13836        if resync_required {
13837            let event = resync_required_event(&shared);
13838            apply_connection_resync_watermark(
13839                &mut pending_events,
13840                &mut discard_events_through,
13841                event.sequence,
13842            );
13843            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13844            resync_required = false;
13845            continue;
13846        }
13847        if !pending_events.is_empty() {
13848            pending_events.sort_unstable_by_key(|event| event.sequence);
13849            let burst_len = connection_event_burst_len(pending_events.len());
13850            shared
13851                .connection_events_sent
13852                .fetch_add(burst_len as u64, Ordering::Relaxed);
13853            for event in pending_events.drain(..burst_len) {
13854                let event = if include_managed_worktrees {
13855                    Some(event)
13856                } else {
13857                    project_event_without_managed_worktrees(event)
13858                };
13859                let event = event.and_then(|event| if include_open_provider_ids {
13860                    Some(event)
13861                } else {
13862                    project_event_legacy_provider_ids(&shared, event)
13863                });
13864                let event = event.map(|event| if include_child_environment_profiles {
13865                    event
13866                } else {
13867                    project_event_without_child_environment_profile(event)
13868                });
13869                let event = event.map(|event| if include_session_bundles {
13870                    event
13871                } else {
13872                    project_event_without_session_bundle(event)
13873                });
13874                let event = event.map(|event| {
13875                    project_event_history_for_wire(event, include_history_context_packs)
13876                });
13877                let event = event.map(|event| if include_history_context_packs {
13878                    event
13879                } else {
13880                    project_event_without_context_pack(event)
13881                });
13882                let event = event.map(|event| if include_session_task_correlation {
13883                    event
13884                } else {
13885                    project_event_without_session_task_binding(event)
13886                });
13887                let exact_harness_controller = hello.role == ClientRole::Operator
13888                    && shared.controller_state().is_some_and(|controller| {
13889                        controller.connection_id == connection_id
13890                    });
13891                let event = event.and_then(|event| {
13892                    if include_harness_mcp_proxy && exact_harness_controller {
13893                        Some(event)
13894                    } else {
13895                        project_event_without_harness_mcp_proxy(event)
13896                    }
13897                });
13898                if let Some(event) = event {
13899                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13900                }
13901            }
13902        }
13903        tokio::select! {
13904            biased;
13905            frame = frame_rx.recv() => {
13906                let frame = match frame {
13907                    Some(frame) => frame,
13908                    None => break,
13909                };
13910                let frame = match frame {
13911                    Ok(frame) => frame,
13912                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13913                    Err(error) => return Err(error.into()),
13914                };
13915                let ClientFrame::Request(request) = frame else {
13916                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13917                };
13918                #[cfg(feature = "fixture")]
13919                let probe_spawn_managed_worktree_v2 = matches!(
13920                    &request.request,
13921                    NodeRequest::SpawnManagedWorktreeV2 { .. }
13922                );
13923                let requires_open_provider_ids =
13924                    request_requires_open_provider_ids(&shared, &request.request);
13925                let requires_child_environment_profile =
13926                    request_requires_child_environment_profile(&shared, &request.request);
13927                let requires_session_bundle =
13928                    request_requires_session_bundle(&shared, &request.request);
13929                let requires_history_context_pack =
13930                    request_requires_history_context_pack(&shared, &request.request);
13931                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13932                    ResponseEnvelope {
13933                        request_id: request.request_id,
13934                        result: Err(failure(
13935                            NodeFailureCode::UnsupportedCapability,
13936                            "open provider IDs were not negotiated",
13937                        )),
13938                    }
13939                } else if requires_child_environment_profile
13940                    && !include_child_environment_profiles
13941                {
13942                    ResponseEnvelope {
13943                        request_id: request.request_id,
13944                        result: Err(failure(
13945                            NodeFailureCode::UnsupportedCapability,
13946                            "child environment profiles were not negotiated",
13947                        )),
13948                    }
13949                } else if requires_session_bundle && !include_session_bundles {
13950                    ResponseEnvelope {
13951                        request_id: request.request_id,
13952                        result: Err(failure(
13953                            NodeFailureCode::UnsupportedCapability,
13954                            "session bundles were not negotiated",
13955                        )),
13956                    }
13957                } else if requires_history_context_pack && !include_history_context_packs {
13958                    ResponseEnvelope {
13959                        request_id: request.request_id,
13960                        result: Err(failure(
13961                            NodeFailureCode::UnsupportedCapability,
13962                            "history context packs were not negotiated",
13963                        )),
13964                    }
13965                } else if request_uses_unnegotiated_capability(
13966                    &request.request,
13967                    &selected_capabilities,
13968                )
13969                {
13970                    ResponseEnvelope {
13971                        request_id: request.request_id,
13972                        result: Err(failure(
13973                            NodeFailureCode::UnsupportedCapability,
13974                            "request capability was not negotiated",
13975                        )),
13976                    }
13977                } else {
13978                    process_request(&shared, connection_id, hello.role, request).await
13979                };
13980                #[cfg(feature = "fixture")]
13981                if probe_spawn_managed_worktree_v2 {
13982                    shared
13983                        .fixture_spawn_managed_worktree_v2_failure_probe
13984                        .record(reply.result.as_ref().err().map(|failure| failure.code));
13985                }
13986                if !include_provider_runtime_status {
13987                    clear_response_provider_runtime_status(&mut reply);
13988                }
13989                if !include_managed_worktrees {
13990                    project_response_without_managed_worktrees(&mut reply);
13991                }
13992                if !include_open_provider_ids {
13993                    project_response_legacy_provider_ids(&shared, &mut reply);
13994                }
13995                if !include_child_environment_profiles {
13996                    project_response_without_child_environment_profile(&mut reply);
13997                }
13998                if !include_session_bundles {
13999                    project_response_without_session_bundle(&mut reply);
14000                }
14001                project_response_history_for_wire(
14002                    &mut reply,
14003                    include_history_context_packs,
14004                );
14005                if !include_history_context_packs {
14006                    project_response_without_context_pack(&mut reply);
14007                }
14008                if !include_agent_progress {
14009                    project_response_without_agent_progress(&mut reply);
14010                }
14011                if !include_session_task_correlation {
14012                    project_response_without_session_task_binding(&mut reply);
14013                }
14014                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14015            }
14016            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14017            event = event_rx.recv() => {
14018                match event {
14019                    Ok(event) => queue_connection_event(
14020                        &mut pending_events,
14021                        event,
14022                        discard_events_through,
14023                    ),
14024                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14025                    Err(broadcast::error::RecvError::Closed) => break,
14026                }
14027            }
14028            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14029                match events {
14030                    Ok(events) => queue_connection_event_batch(
14031                        &mut pending_events,
14032                        &events,
14033                        discard_events_through,
14034                    ),
14035                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14036                    Err(broadcast::error::RecvError::Closed) => break,
14037                }
14038            }
14039            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14040                match events {
14041                    Ok(events) => queue_connection_event_batch(
14042                        &mut pending_events,
14043                        &events,
14044                        discard_events_through,
14045                    ),
14046                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14047                    Err(broadcast::error::RecvError::Closed) => break,
14048                }
14049            }
14050            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14051                match events {
14052                    Ok(events) => queue_connection_event_batch(
14053                        &mut pending_events,
14054                        &events,
14055                        discard_events_through,
14056                    ),
14057                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14058                    Err(broadcast::error::RecvError::Closed) => break,
14059                }
14060            }
14061            _ = shared.shutdown_notify.notified() => break,
14062        }
14063    }
14064    Ok(())
14065}
14066
14067async fn receive_terminal_event_batch(
14068    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14069) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14070    match receiver {
14071        Some(receiver) => receiver.recv().await,
14072        None => std::future::pending().await,
14073    }
14074}
14075
14076fn terminal_event_subscription(
14077    shared: &NodeShared,
14078    enabled: bool,
14079) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14080    enabled.then(|| shared.terminal_event_tx.subscribe())
14081}
14082
14083/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
14084/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
14085/// this needs a dedicated channel rather than a flag on the terminal one.
14086fn agent_stream_event_subscription(
14087    shared: &NodeShared,
14088    enabled: bool,
14089) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14090    enabled.then(|| shared.agent_stream_event_tx.subscribe())
14091}
14092
14093/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
14094/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
14095/// own doc for why this needs a dedicated channel rather than a ride on
14096/// the durable `event_tx`.
14097fn harness_mcp_proxy_event_subscription(
14098    shared: &NodeShared,
14099    enabled: bool,
14100) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14101    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14102}
14103
14104fn queue_connection_event(
14105    pending: &mut Vec<NodeEventEnvelope>,
14106    event: NodeEventEnvelope,
14107    discard_through: u64,
14108) {
14109    if event.sequence <= discard_through
14110        && !event.event.requires_harness_mcp_proxy_capability()
14111    {
14112        return;
14113    }
14114    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14115        if let Some(index) = pending.iter().position(|current| {
14116            matches!(
14117                &current.event,
14118                NodeEvent::TerminalFrame {
14119                    address: current_address,
14120                    ..
14121                } if current_address == address
14122            )
14123        }) {
14124            pending.remove(index);
14125        }
14126    }
14127    pending.push(event);
14128}
14129
14130fn project_event_without_harness_mcp_proxy(
14131    envelope: NodeEventEnvelope,
14132) -> Option<NodeEventEnvelope> {
14133    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14134}
14135
14136fn queue_connection_event_batch(
14137    pending: &mut Vec<NodeEventEnvelope>,
14138    events: &[NodeEventEnvelope],
14139    discard_through: u64,
14140) {
14141    for event in events {
14142        queue_connection_event(pending, event.clone(), discard_through);
14143    }
14144}
14145
14146fn apply_connection_resync_watermark(
14147    pending: &mut Vec<NodeEventEnvelope>,
14148    discard_through: &mut u64,
14149    marker_sequence: u64,
14150) {
14151    *discard_through = (*discard_through).max(marker_sequence);
14152    pending.retain(|event| event.sequence > *discard_through);
14153}
14154
14155fn connection_event_burst_len(pending_len: usize) -> usize {
14156    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14157}
14158
14159fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14160    let history = shared
14161        .history
14162        .lock()
14163        .unwrap_or_else(|poisoned| poisoned.into_inner());
14164    let sequence = history.last_sequence;
14165    let oldest_available_sequence = history.replay_floor_sequence;
14166    NodeEventEnvelope {
14167        sequence,
14168        event: NodeEvent::ResyncRequired {
14169            oldest_available_sequence,
14170        },
14171    }
14172}
14173
14174fn node_compatibility_support(
14175    shared: &NodeShared,
14176) -> Result<NodeCompatibilitySupport, NodeServerError> {
14177    let mut support = node_compatibility_support_for_manifest(
14178        &shared.provider_contracts,
14179        &shared.provider_adapter_contracts,
14180    )?;
14181    if shared
14182        .delivery_store
14183        .lock()
14184        .unwrap_or_else(|poisoned| poisoned.into_inner())
14185        .is_some()
14186    {
14187        support.capabilities.push(
14188            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14189                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14190        );
14191    }
14192    if shared.harness_mcp_registry.is_some() {
14193        support.capabilities.push(
14194            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14195                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14196        );
14197    }
14198    Ok(support)
14199}
14200
14201fn node_compatibility_support_for_manifest(
14202    provider_contracts: &[ProviderContractSupport],
14203    provider_adapter_contracts: &[ProviderAdapterContractSupport],
14204) -> Result<NodeCompatibilitySupport, NodeServerError> {
14205    Ok(NodeCompatibilitySupport {
14206        build_stamp: BUILD_STAMP.to_owned(),
14207        capabilities: baseline_capabilities()?,
14208        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14209        path_semantics: platform::path_semantics(),
14210        local_transport: platform::local_transport(),
14211        state_schema: StateSchemaSupport {
14212            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14213                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14214        },
14215        provider_contracts: provider_contracts.to_vec(),
14216        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14217    })
14218}
14219
14220fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14221    let capabilities = [
14222        NODE_COMPATIBILITY_METADATA_CAPABILITY,
14223        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14224        NODE_REPOSITORY_PATH_CAPABILITY,
14225        NODE_WORKSPACE_FILE_READ_CAPABILITY,
14226        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14227        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14228        NODE_GIT_READ_CAPABILITY,
14229        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14230        NODE_PROVIDER_ID_OPEN_CAPABILITY,
14231        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14232        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14233        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14234        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14235        NODE_ACP_CONTROL_CAPABILITY,
14236        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14237        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14238        NODE_WORKTREE_SELECTION_CAPABILITY,
14239        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14240        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14241        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14242        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14243        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14244        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14245        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14246        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14247        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14248        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14249        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14250        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14251    ]
14252        .into_iter()
14253        .map(|capability| {
14254            CapabilityId::new(capability)
14255                .map_err(|error| NodeServerError::Handshake(error.to_string()))
14256        })
14257        .collect::<Result<Vec<_>, _>>()?;
14258    #[cfg(windows)]
14259    let capabilities = {
14260        let mut capabilities = capabilities;
14261        capabilities.push(
14262            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14263                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14264        );
14265        capabilities
14266    };
14267    Ok(capabilities)
14268}
14269
14270fn request_uses_unnegotiated_capability(
14271    request: &NodeRequest,
14272    selected_capabilities: &[CapabilityId],
14273) -> bool {
14274    let selected = |required: &str| {
14275        selected_capabilities
14276            .iter()
14277            .any(|capability| capability.as_str() == required)
14278    };
14279    request
14280        .required_capability()
14281        .is_some_and(|required| !selected(required))
14282        || (request.requires_worktree_selection_capability()
14283            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14284        || (request.requires_spawn_spec_defaults_overrides_capability()
14285            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14286        || (request.requires_spawn_profile_revision_capability()
14287            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14288        || (request.requires_child_environment_profile_capability()
14289            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14290        || (request.requires_session_bundle_materialization_capability()
14291            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14292        || (request.requires_history_context_pack_capability()
14293            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14294}
14295
14296fn snapshot_for_wire(
14297    shared: &NodeShared,
14298    include_provider_runtime_status: bool,
14299    include_open_provider_ids: bool,
14300    include_spawn_profiles: bool,
14301    include_managed_worktrees: bool,
14302    include_child_environment_profiles: bool,
14303    include_session_bundles: bool,
14304    include_history_context_packs: bool,
14305    include_agent_progress: bool,
14306    include_session_task_correlation: bool,
14307) -> NodeSnapshot {
14308    let mut snapshot = shared.snapshot();
14309    if !include_provider_runtime_status {
14310        snapshot.provider_runtime_statuses.clear();
14311    }
14312    if !include_open_provider_ids {
14313        project_snapshot_legacy_provider_ids(&mut snapshot);
14314    }
14315    if !include_managed_worktrees {
14316        snapshot.managed_worktrees.clear();
14317        for workspace in &mut snapshot.workspaces {
14318            workspace.managed_worktree_profiles = None;
14319        }
14320    }
14321    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14322        if !include_spawn_profiles {
14323            inventory.spawn_profiles = None;
14324        }
14325        if !include_session_bundles {
14326            inventory.bundles = None;
14327        }
14328        if inventory.spawn_profiles.is_none()
14329            && inventory.bundles.is_none()
14330            && inventory.network_allowlists.is_none()
14331        {
14332            snapshot.launch_inventory = None;
14333        }
14334    }
14335    if !include_child_environment_profiles {
14336        clear_snapshot_child_environment_profiles(&mut snapshot);
14337    }
14338    if !include_session_bundles {
14339        clear_snapshot_session_bundles(&mut snapshot);
14340    }
14341    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14342    if !include_history_context_packs {
14343        clear_snapshot_context_packs(&mut snapshot);
14344    }
14345    if !include_agent_progress {
14346        snapshot.agent_progress.clear();
14347    }
14348    if !include_session_task_correlation {
14349        clear_snapshot_session_task_bindings(&mut snapshot);
14350    }
14351    snapshot
14352}
14353
14354fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14355    for record in &mut snapshot.session_records {
14356        record.task_binding = None;
14357    }
14358}
14359
14360fn project_event_without_session_task_binding(
14361    mut envelope: NodeEventEnvelope,
14362) -> NodeEventEnvelope {
14363    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14364        record.task_binding = None;
14365    }
14366    envelope
14367}
14368
14369fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14370    let Ok(response) = reply.result.as_mut() else { return };
14371    match response {
14372        NodeResponse::Snapshot { snapshot, .. } => {
14373            clear_snapshot_session_task_bindings(snapshot);
14374        }
14375        NodeResponse::Resync { snapshot, events, .. } => {
14376            clear_snapshot_session_task_bindings(snapshot);
14377            for event in events {
14378                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14379                    record.task_binding = None;
14380                }
14381            }
14382        }
14383        NodeResponse::SessionRecordUpdated { record }
14384        | NodeResponse::ProviderSessionIndexed { record }
14385        | NodeResponse::NativeSessionIndexed { record, .. }
14386        | NodeResponse::SessionRecordResumed { record, .. } => {
14387            record.task_binding = None;
14388        }
14389        _ => {}
14390    }
14391}
14392
14393fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14394    let Ok(response) = reply.result.as_mut() else { return };
14395    match response {
14396        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14397            snapshot.agent_progress.clear();
14398        }
14399        _ => {}
14400    }
14401}
14402
14403fn project_event_without_managed_worktrees(
14404    mut envelope: NodeEventEnvelope,
14405) -> Option<NodeEventEnvelope> {
14406    if matches!(
14407        envelope.event,
14408        NodeEvent::ManagedWorktreeUpserted { .. }
14409            | NodeEvent::ManagedWorktreeRemoved { .. }
14410    ) {
14411        return None;
14412    }
14413    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14414        workspace.managed_worktree_profiles = None;
14415    }
14416    Some(envelope)
14417}
14418
14419fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14420    if matches!(
14421        reply.result,
14422        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14423            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14424    ) {
14425        reply.result = Err(failure(
14426            NodeFailureCode::UnsupportedCapability,
14427            "managed worktree capability was not negotiated",
14428        ));
14429        return;
14430    }
14431    let Ok(response) = reply.result.as_mut() else { return };
14432    match response {
14433        NodeResponse::Snapshot { snapshot, .. } => {
14434            snapshot.managed_worktrees.clear();
14435            for workspace in &mut snapshot.workspaces {
14436                workspace.managed_worktree_profiles = None;
14437            }
14438        }
14439        NodeResponse::Resync { snapshot, events, .. } => {
14440            snapshot.managed_worktrees.clear();
14441            for workspace in &mut snapshot.workspaces {
14442                workspace.managed_worktree_profiles = None;
14443            }
14444            *events = std::mem::take(events)
14445                .into_iter()
14446                .filter_map(project_event_without_managed_worktrees)
14447                .collect();
14448        }
14449        NodeResponse::WorkspaceRegistered { workspace }
14450        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14451        | NodeResponse::WorktreeCreated { workspace, .. } => {
14452            workspace.managed_worktree_profiles = None;
14453        }
14454        NodeResponse::WorkspaceInspected { inspection } => {
14455            inspection.git.managed_worktree = None;
14456        }
14457        _ => {}
14458    }
14459}
14460
14461fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14462    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14463        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14464    });
14465    if includes_open_provider_ids {
14466        return;
14467    }
14468    compatibility
14469        .provider_contracts
14470        .retain(|contract| provider_id_is_legacy(&contract.provider));
14471    compatibility
14472        .provider_adapter_contracts
14473        .retain(|contract| provider_id_is_legacy(&contract.provider));
14474}
14475
14476fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14477    if let Some(spec) = match request {
14478        NodeRequest::SpawnSpec { spec }
14479        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14480        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14481        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14482        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14483        _ => None,
14484    } {
14485        return shared
14486            .resolve_spawn_spec(spec)
14487            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14488    }
14489    request_requires_open_provider_ids_with(
14490        request,
14491        |session| shared.validate_address(session).ok(),
14492        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14493    )
14494}
14495
14496fn request_requires_child_environment_profile(
14497    shared: &NodeShared,
14498    request: &NodeRequest,
14499) -> bool {
14500    if let Some(spec) = match request {
14501        NodeRequest::SpawnSpec { spec }
14502        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14503        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14504        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14505        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14506        _ => None,
14507    } {
14508        return shared
14509            .resolve_spawn_spec(spec)
14510            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14511    }
14512    match request {
14513        NodeRequest::Resume { session, .. }
14514        | NodeRequest::Prompt { session, .. }
14515        | NodeRequest::Paste { session, .. }
14516        | NodeRequest::Input { session, .. }
14517        | NodeRequest::TerminalBytes { session, .. }
14518        | NodeRequest::TerminalControl { session, .. }
14519        | NodeRequest::Resize { session, .. }
14520        | NodeRequest::Interrupt { session }
14521        | NodeRequest::Stop { session, .. }
14522        | NodeRequest::Remove { session }
14523        | NodeRequest::ResolveInteraction { session, .. }
14524        | NodeRequest::SetSessionMode { session, .. }
14525        | NodeRequest::SetSessionConfigOption { session, .. }
14526        | NodeRequest::SetSessionModel { session, .. }
14527        | NodeRequest::DiscoverHistory { session, .. }
14528        | NodeRequest::LoadHistory { session, .. }
14529        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14530        | NodeRequest::ExportContextPack { session } => shared
14531            .session_bindings
14532            .lock()
14533            .unwrap_or_else(|poisoned| poisoned.into_inner())
14534            .get(&session.session.instance_id)
14535            .filter(|binding| {
14536                binding.workspace_id == session.workspace_id
14537                    && binding.generation == session.session.generation
14538            })
14539            .map_or(true, |binding| binding.environment_profile.is_some()),
14540        NodeRequest::RenameSessionRecord { record_id, .. }
14541        | NodeRequest::SetSessionTask { record_id, .. }
14542        | NodeRequest::ResumeSessionRecord { record_id, .. }
14543        | NodeRequest::ForgetSessionRecord { record_id } => shared
14544            .record(record_id)
14545            .map_or(true, |record| record.environment_profile.is_some()),
14546        NodeRequest::ArmHarnessMcpReservation { .. }
14547        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14548        | NodeRequest::ActivateHarnessMcpReservation { .. }
14549        | NodeRequest::AbortHarnessMcpReservation { .. }
14550        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14551        | NodeRequest::RejectHarnessMcpCall { .. }
14552        | NodeRequest::Snapshot
14553        | NodeRequest::Resync { .. }
14554        | NodeRequest::BeginDeliveryStage { .. }
14555        | NodeRequest::PutDeliveryBlobChunk { .. }
14556        | NodeRequest::CommitDeliveryStage { .. }
14557        | NodeRequest::AbortDeliveryStage { .. }
14558        | NodeRequest::BrowseHostDirectories { .. }
14559        | NodeRequest::InspectWorkspace { .. }
14560        | NodeRequest::ReadWorkspaceFile { .. }
14561        | NodeRequest::WriteWorkspaceFile { .. }
14562        | NodeRequest::CreateWorkspaceFile { .. }
14563        | NodeRequest::CreateWorkspaceDirectory { .. }
14564        | NodeRequest::ReadGitHistory { .. }
14565        | NodeRequest::ReadGitDiff { .. }
14566        | NodeRequest::AcquireController { .. }
14567        | NodeRequest::ReleaseController
14568        | NodeRequest::Spawn { .. }
14569        | NodeRequest::SpawnWithOptions { .. }
14570        | NodeRequest::SpawnSpec { .. }
14571        | NodeRequest::RegisterWorkspace { .. }
14572        | NodeRequest::CreateStandaloneWorkspace { .. }
14573        | NodeRequest::UnregisterWorkspace { .. }
14574        | NodeRequest::CreateWorktree { .. }
14575        | NodeRequest::RemoveWorktree { .. }
14576        | NodeRequest::SpawnManagedWorktree { .. }
14577        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14578        | NodeRequest::CleanupManagedWorktree { .. }
14579        | NodeRequest::IndexProviderSession { .. }
14580        | NodeRequest::IndexNativeSession { .. }
14581        | NodeRequest::CatalogNativeSessions { .. }
14582        | NodeRequest::PageNativeSessions { .. }
14583        | NodeRequest::PreviewNativeSession { .. }
14584        | NodeRequest::PreviewSessionRecord { .. }
14585        | NodeRequest::ForgetContextPack { .. }
14586        | NodeRequest::ResolveDurableContextPack { .. }
14587        | NodeRequest::ReadContextPack { .. }
14588        | NodeRequest::Shutdown => false,
14589    }
14590}
14591
14592fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14593    if let Some(spec) = match request {
14594        NodeRequest::SpawnSpec { spec }
14595        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14596        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14597        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14598        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14599        _ => None,
14600    } {
14601        return shared
14602            .resolve_spawn_spec(spec)
14603            .is_ok_and(|resolved| resolved.bundle_id.is_some());
14604    }
14605    match request {
14606        NodeRequest::Resume { session, .. }
14607        | NodeRequest::Prompt { session, .. }
14608        | NodeRequest::Paste { session, .. }
14609        | NodeRequest::Input { session, .. }
14610        | NodeRequest::TerminalBytes { session, .. }
14611        | NodeRequest::TerminalControl { session, .. }
14612        | NodeRequest::Resize { session, .. }
14613        | NodeRequest::Interrupt { session }
14614        | NodeRequest::Stop { session, .. }
14615        | NodeRequest::Remove { session }
14616        | NodeRequest::ResolveInteraction { session, .. }
14617        | NodeRequest::SetSessionMode { session, .. }
14618        | NodeRequest::SetSessionConfigOption { session, .. }
14619        | NodeRequest::SetSessionModel { session, .. }
14620        | NodeRequest::DiscoverHistory { session, .. }
14621        | NodeRequest::LoadHistory { session, .. }
14622        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14623        | NodeRequest::ExportContextPack { session } => shared
14624            .session_bindings
14625            .lock()
14626            .unwrap_or_else(|poisoned| poisoned.into_inner())
14627            .get(&session.session.instance_id)
14628            .filter(|binding| {
14629                binding.workspace_id == session.workspace_id
14630                    && binding.generation == session.session.generation
14631            })
14632            .map_or(true, |binding| binding.bundle.is_some()),
14633        NodeRequest::RenameSessionRecord { record_id, .. }
14634        | NodeRequest::SetSessionTask { record_id, .. }
14635        | NodeRequest::ResumeSessionRecord { record_id, .. }
14636        | NodeRequest::ForgetSessionRecord { record_id } => shared
14637            .record(record_id)
14638            .map_or(true, |record| record.bundle.is_some()),
14639        NodeRequest::ArmHarnessMcpReservation { .. }
14640        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14641        | NodeRequest::ActivateHarnessMcpReservation { .. }
14642        | NodeRequest::AbortHarnessMcpReservation { .. }
14643        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14644        | NodeRequest::RejectHarnessMcpCall { .. }
14645        | NodeRequest::Snapshot
14646        | NodeRequest::Resync { .. }
14647        | NodeRequest::BeginDeliveryStage { .. }
14648        | NodeRequest::PutDeliveryBlobChunk { .. }
14649        | NodeRequest::CommitDeliveryStage { .. }
14650        | NodeRequest::AbortDeliveryStage { .. }
14651        | NodeRequest::BrowseHostDirectories { .. }
14652        | NodeRequest::InspectWorkspace { .. }
14653        | NodeRequest::ReadWorkspaceFile { .. }
14654        | NodeRequest::WriteWorkspaceFile { .. }
14655        | NodeRequest::CreateWorkspaceFile { .. }
14656        | NodeRequest::CreateWorkspaceDirectory { .. }
14657        | NodeRequest::ReadGitHistory { .. }
14658        | NodeRequest::ReadGitDiff { .. }
14659        | NodeRequest::AcquireController { .. }
14660        | NodeRequest::ReleaseController
14661        | NodeRequest::Spawn { .. }
14662        | NodeRequest::SpawnWithOptions { .. }
14663        | NodeRequest::SpawnSpec { .. }
14664        | NodeRequest::RegisterWorkspace { .. }
14665        | NodeRequest::CreateStandaloneWorkspace { .. }
14666        | NodeRequest::UnregisterWorkspace { .. }
14667        | NodeRequest::CreateWorktree { .. }
14668        | NodeRequest::RemoveWorktree { .. }
14669        | NodeRequest::SpawnManagedWorktree { .. }
14670        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14671        | NodeRequest::CleanupManagedWorktree { .. }
14672        | NodeRequest::IndexProviderSession { .. }
14673        | NodeRequest::IndexNativeSession { .. }
14674        | NodeRequest::CatalogNativeSessions { .. }
14675        | NodeRequest::PageNativeSessions { .. }
14676        | NodeRequest::PreviewNativeSession { .. }
14677        | NodeRequest::PreviewSessionRecord { .. }
14678        | NodeRequest::ForgetContextPack { .. }
14679        | NodeRequest::ResolveDurableContextPack { .. }
14680        | NodeRequest::ReadContextPack { .. }
14681        | NodeRequest::Shutdown => false,
14682    }
14683}
14684
14685fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14686    if request.requires_history_context_pack_capability() {
14687        return true;
14688    }
14689    if let Some(spec) = match request {
14690        NodeRequest::SpawnSpec { spec }
14691        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14692        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14693        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14694        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14695        _ => None,
14696    } {
14697        return shared
14698            .resolve_spawn_spec(spec)
14699            .map_or(true, |resolved| resolved.context_id.is_some());
14700    }
14701    match request {
14702        NodeRequest::Resume { session, .. }
14703        | NodeRequest::Prompt { session, .. }
14704        | NodeRequest::Paste { session, .. }
14705        | NodeRequest::Input { session, .. }
14706        | NodeRequest::TerminalBytes { session, .. }
14707        | NodeRequest::TerminalControl { session, .. }
14708        | NodeRequest::Resize { session, .. }
14709        | NodeRequest::Interrupt { session }
14710        | NodeRequest::Stop { session, .. }
14711        | NodeRequest::Remove { session }
14712        | NodeRequest::ResolveInteraction { session, .. }
14713        | NodeRequest::SetSessionMode { session, .. }
14714        | NodeRequest::SetSessionConfigOption { session, .. }
14715        | NodeRequest::SetSessionModel { session, .. } => shared
14716            .session_bindings
14717            .lock()
14718            .unwrap_or_else(|poisoned| poisoned.into_inner())
14719            .get(&session.session.instance_id)
14720            .filter(|binding| {
14721                binding.workspace_id == session.workspace_id
14722                    && binding.generation == session.session.generation
14723            })
14724            .map_or(true, |binding| binding.context.is_some()),
14725        NodeRequest::RenameSessionRecord { record_id, .. }
14726        | NodeRequest::SetSessionTask { record_id, .. }
14727        | NodeRequest::ResumeSessionRecord { record_id, .. }
14728        | NodeRequest::ForgetSessionRecord { record_id } => shared
14729            .record(record_id)
14730            .map_or(true, |record| record.context.is_some()),
14731        NodeRequest::ArmHarnessMcpReservation { .. }
14732        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14733        | NodeRequest::ActivateHarnessMcpReservation { .. }
14734        | NodeRequest::AbortHarnessMcpReservation { .. }
14735        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14736        | NodeRequest::RejectHarnessMcpCall { .. }
14737        | NodeRequest::Snapshot
14738        | NodeRequest::Resync { .. }
14739        | NodeRequest::BeginDeliveryStage { .. }
14740        | NodeRequest::PutDeliveryBlobChunk { .. }
14741        | NodeRequest::CommitDeliveryStage { .. }
14742        | NodeRequest::AbortDeliveryStage { .. }
14743        | NodeRequest::BrowseHostDirectories { .. }
14744        | NodeRequest::InspectWorkspace { .. }
14745        | NodeRequest::ReadWorkspaceFile { .. }
14746        | NodeRequest::WriteWorkspaceFile { .. }
14747        | NodeRequest::CreateWorkspaceFile { .. }
14748        | NodeRequest::CreateWorkspaceDirectory { .. }
14749        | NodeRequest::ReadGitHistory { .. }
14750        | NodeRequest::ReadGitDiff { .. }
14751        | NodeRequest::AcquireController { .. }
14752        | NodeRequest::ReleaseController
14753        | NodeRequest::Spawn { .. }
14754        | NodeRequest::SpawnWithOptions { .. }
14755        | NodeRequest::SpawnSpec { .. }
14756        | NodeRequest::RegisterWorkspace { .. }
14757        | NodeRequest::CreateStandaloneWorkspace { .. }
14758        | NodeRequest::UnregisterWorkspace { .. }
14759        | NodeRequest::CreateWorktree { .. }
14760        | NodeRequest::RemoveWorktree { .. }
14761        | NodeRequest::SpawnManagedWorktree { .. }
14762        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14763        | NodeRequest::CleanupManagedWorktree { .. }
14764        | NodeRequest::IndexProviderSession { .. }
14765        | NodeRequest::IndexNativeSession { .. }
14766        | NodeRequest::CatalogNativeSessions { .. }
14767        | NodeRequest::PageNativeSessions { .. }
14768        | NodeRequest::PreviewNativeSession { .. }
14769        | NodeRequest::PreviewSessionRecord { .. }
14770        | NodeRequest::DiscoverHistory { .. }
14771        | NodeRequest::LoadHistory { .. }
14772        | NodeRequest::ExportContextPackForSessionRecord { .. }
14773        | NodeRequest::ExportContextPack { .. }
14774        | NodeRequest::ForgetContextPack { .. }
14775        | NodeRequest::ResolveDurableContextPack { .. }
14776        | NodeRequest::ReadContextPack { .. }
14777        | NodeRequest::Shutdown => false,
14778    }
14779}
14780
14781fn request_requires_open_provider_ids_with(
14782    request: &NodeRequest,
14783    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14784    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14785) -> bool {
14786    match request {
14787        NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
14788        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14789        NodeRequest::CatalogNativeSessions { route, .. }
14790        | NodeRequest::PageNativeSessions { route, .. } => {
14791            !provider_id_is_legacy(&route.provider)
14792        }
14793        NodeRequest::PreviewNativeSession { selection, .. }
14794        | NodeRequest::IndexNativeSession { selection, .. } => {
14795            !provider_id_is_legacy(&selection.route.provider)
14796        }
14797        NodeRequest::SpawnSpec { .. }
14798        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14799        NodeRequest::SpawnManagedWorktree { .. }
14800        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14801        NodeRequest::Resume { session, .. }
14802        | NodeRequest::Prompt { session, .. }
14803        | NodeRequest::Paste { session, .. }
14804        | NodeRequest::Input { session, .. }
14805        | NodeRequest::TerminalBytes { session, .. }
14806        | NodeRequest::TerminalControl { session, .. }
14807        | NodeRequest::Resize { session, .. }
14808        | NodeRequest::Interrupt { session }
14809        | NodeRequest::Stop { session, .. }
14810        | NodeRequest::Remove { session }
14811        | NodeRequest::ResolveInteraction { session, .. }
14812        | NodeRequest::SetSessionMode { session, .. }
14813        | NodeRequest::SetSessionConfigOption { session, .. }
14814        | NodeRequest::SetSessionModel { session, .. }
14815        | NodeRequest::DiscoverHistory { session, .. }
14816        | NodeRequest::LoadHistory { session, .. }
14817        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14818        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14819            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14820        NodeRequest::RenameSessionRecord { record_id, .. }
14821        | NodeRequest::SetSessionTask { record_id, .. }
14822        | NodeRequest::ResumeSessionRecord { record_id, .. }
14823        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14824            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14825        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14826            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14827        NodeRequest::ArmHarnessMcpReservation { .. }
14828        | NodeRequest::ActivateHarnessMcpReservation { .. }
14829        | NodeRequest::AbortHarnessMcpReservation { .. }
14830        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14831        | NodeRequest::RejectHarnessMcpCall { .. }
14832        | NodeRequest::Snapshot
14833        | NodeRequest::Resync { .. }
14834        | NodeRequest::BeginDeliveryStage { .. }
14835        | NodeRequest::PutDeliveryBlobChunk { .. }
14836        | NodeRequest::CommitDeliveryStage { .. }
14837        | NodeRequest::AbortDeliveryStage { .. }
14838        | NodeRequest::BrowseHostDirectories { .. }
14839        | NodeRequest::InspectWorkspace { .. }
14840        | NodeRequest::ReadWorkspaceFile { .. }
14841        | NodeRequest::WriteWorkspaceFile { .. }
14842        | NodeRequest::CreateWorkspaceFile { .. }
14843        | NodeRequest::CreateWorkspaceDirectory { .. }
14844        | NodeRequest::ReadGitHistory { .. }
14845        | NodeRequest::ReadGitDiff { .. }
14846        | NodeRequest::AcquireController { .. }
14847        | NodeRequest::ReleaseController
14848        | NodeRequest::RegisterWorkspace { .. }
14849        | NodeRequest::CreateStandaloneWorkspace { .. }
14850        | NodeRequest::UnregisterWorkspace { .. }
14851        | NodeRequest::CreateWorktree { .. }
14852        | NodeRequest::RemoveWorktree { .. }
14853        | NodeRequest::CleanupManagedWorktree { .. }
14854        | NodeRequest::Shutdown => false,
14855        NodeRequest::ForgetContextPack { .. } => true,
14856        // No session/record to cheaply resolve a provider from here either;
14857        // the resolved receipt can carry an open-id provider, so require the
14858        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
14859        // is the same digest-keyed lookup one step further into the same
14860        // catalog, so it inherits the same conservative answer.
14861        NodeRequest::ResolveDurableContextPack { .. }
14862        | NodeRequest::ReadContextPack { .. } => true,
14863    }
14864}
14865
14866fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14867    snapshot.enabled_providers.retain(provider_id_is_legacy);
14868    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14869        snapshot
14870            .provider_runtime_statuses
14871            .as_slice()
14872            .iter()
14873            .filter(|status| provider_id_is_legacy(status.provider()))
14874            .cloned(),
14875    )
14876    .expect("a filtered provider runtime projection remains valid");
14877    snapshot
14878        .session_records
14879        .retain(|record| provider_id_is_legacy(&record.provider));
14880    for workspace in &mut snapshot.workspaces {
14881        workspace
14882            .sessions
14883            .retain(|session| provider_id_is_legacy(&session.agent_id));
14884    }
14885}
14886
14887fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14888    for record in &mut snapshot.session_records {
14889        record.environment_profile = None;
14890    }
14891    if let Some(profiles) = snapshot
14892        .launch_inventory
14893        .as_mut()
14894        .and_then(|inventory| inventory.spawn_profiles.as_mut())
14895    {
14896        for profile in profiles {
14897            profile.environment_profile = None;
14898        }
14899    }
14900}
14901
14902fn project_event_without_child_environment_profile(
14903    mut envelope: NodeEventEnvelope,
14904) -> NodeEventEnvelope {
14905    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14906        record.environment_profile = None;
14907    }
14908    envelope
14909}
14910
14911fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14912    let contains_environment_profile = match reply.result.as_ref() {
14913        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14914            receipt.environment_profile.is_some()
14915        }
14916        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14917            receipt.spawn.environment_profile.is_some()
14918        }
14919        _ => false,
14920    };
14921    if contains_environment_profile {
14922        reply.result = Err(failure(
14923            NodeFailureCode::UnsupportedCapability,
14924            "child environment profiles were not negotiated",
14925        ));
14926        return;
14927    }
14928    let Ok(response) = reply.result.as_mut() else {
14929        return;
14930    };
14931    match response {
14932        NodeResponse::Snapshot { snapshot, .. } => {
14933            clear_snapshot_child_environment_profiles(snapshot);
14934        }
14935        NodeResponse::Resync { snapshot, events, .. } => {
14936            clear_snapshot_child_environment_profiles(snapshot);
14937            for event in events {
14938                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14939                    record.environment_profile = None;
14940                }
14941            }
14942        }
14943        NodeResponse::SessionRecordUpdated { record }
14944        | NodeResponse::ProviderSessionIndexed { record }
14945        | NodeResponse::NativeSessionIndexed { record, .. }
14946        | NodeResponse::SessionRecordResumed { record, .. } => {
14947            record.environment_profile = None;
14948        }
14949        NodeResponse::Armed { .. }
14950        | NodeResponse::Spawned { .. }
14951        | NodeResponse::Activated { .. }
14952        | NodeResponse::Aborted { .. }
14953        | NodeResponse::ReplyChunkAccepted { .. }
14954        | NodeResponse::CallRejected { .. }
14955        | NodeResponse::DeliveryStageBegun { .. }
14956        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14957        | NodeResponse::DeliveryCommitted { .. }
14958        | NodeResponse::DeliveryStageAborted { .. }
14959        | NodeResponse::WorkspaceInspected { .. }
14960        | NodeResponse::HostDirectoriesBrowsed { .. }
14961        | NodeResponse::WorkspaceFileRead { .. }
14962        | NodeResponse::WorkspaceFileWritten { .. }
14963        | NodeResponse::WorkspaceFileCreated { .. }
14964        | NodeResponse::WorkspaceDirectoryCreated { .. }
14965        | NodeResponse::GitHistoryRead { .. }
14966        | NodeResponse::GitDiffRead { .. }
14967        | NodeResponse::Controller { .. }
14968        | NodeResponse::SpawnAccepted { .. }
14969        | NodeResponse::SpawnSpecAccepted { .. }
14970        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14971        | NodeResponse::ManagedWorktreeCleanup { .. }
14972        | NodeResponse::NativeSessionsCataloged { .. }
14973        | NodeResponse::NativeSessionsPaged { .. }
14974        | NodeResponse::NativeSessionPreviewed { .. }
14975        | NodeResponse::SessionRecordPreviewed { .. }
14976        | NodeResponse::HistoryDiscovered { .. }
14977        | NodeResponse::HistoryLoaded { .. }
14978        | NodeResponse::ContextPackForSessionRecordExported { .. }
14979        | NodeResponse::ContextPackExported { .. }
14980        | NodeResponse::ContextPackForgotten { .. }
14981        | NodeResponse::DurableContextPackResolved { .. }
14982        | NodeResponse::ContextPackBytesRead { .. }
14983        | NodeResponse::SessionRecordForgotten { .. }
14984        | NodeResponse::WorkspaceRegistered { .. }
14985        | NodeResponse::StandaloneWorkspaceCreated { .. }
14986        | NodeResponse::WorkspaceUnregistered { .. }
14987        | NodeResponse::WorktreeCreated { .. }
14988        | NodeResponse::WorktreeRemoved { .. }
14989        | NodeResponse::Accepted
14990        | NodeResponse::ShuttingDown => {}
14991    }
14992}
14993
14994fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14995    for record in &mut snapshot.session_records {
14996        record.bundle = None;
14997    }
14998}
14999
15000fn project_event_without_session_bundle(
15001    mut envelope: NodeEventEnvelope,
15002) -> NodeEventEnvelope {
15003    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15004        record.bundle = None;
15005    }
15006    envelope
15007}
15008
15009fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15010    let contains_bundle = match reply.result.as_ref() {
15011        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15012        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15013            receipt.spawn.bundle.is_some()
15014        }
15015        _ => false,
15016    };
15017    if contains_bundle {
15018        reply.result = Err(failure(
15019            NodeFailureCode::UnsupportedCapability,
15020            "session bundles were not negotiated",
15021        ));
15022        return;
15023    }
15024    let Ok(response) = reply.result.as_mut() else {
15025        return;
15026    };
15027    match response {
15028        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15029        NodeResponse::Resync { snapshot, events, .. } => {
15030            clear_snapshot_session_bundles(snapshot);
15031            for event in events {
15032                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15033                    record.bundle = None;
15034                }
15035            }
15036        }
15037        NodeResponse::SessionRecordUpdated { record }
15038        | NodeResponse::ProviderSessionIndexed { record }
15039        | NodeResponse::NativeSessionIndexed { record, .. }
15040        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15041        NodeResponse::Armed { .. }
15042        | NodeResponse::Spawned { .. }
15043        | NodeResponse::Activated { .. }
15044        | NodeResponse::Aborted { .. }
15045        | NodeResponse::ReplyChunkAccepted { .. }
15046        | NodeResponse::CallRejected { .. }
15047        | NodeResponse::DeliveryStageBegun { .. }
15048        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15049        | NodeResponse::DeliveryCommitted { .. }
15050        | NodeResponse::DeliveryStageAborted { .. }
15051        | NodeResponse::WorkspaceInspected { .. }
15052        | NodeResponse::HostDirectoriesBrowsed { .. }
15053        | NodeResponse::WorkspaceFileRead { .. }
15054        | NodeResponse::WorkspaceFileWritten { .. }
15055        | NodeResponse::WorkspaceFileCreated { .. }
15056        | NodeResponse::WorkspaceDirectoryCreated { .. }
15057        | NodeResponse::GitHistoryRead { .. }
15058        | NodeResponse::GitDiffRead { .. }
15059        | NodeResponse::Controller { .. }
15060        | NodeResponse::SpawnAccepted { .. }
15061        | NodeResponse::SpawnSpecAccepted { .. }
15062        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15063        | NodeResponse::ManagedWorktreeCleanup { .. }
15064        | NodeResponse::NativeSessionsCataloged { .. }
15065        | NodeResponse::NativeSessionsPaged { .. }
15066        | NodeResponse::NativeSessionPreviewed { .. }
15067        | NodeResponse::SessionRecordPreviewed { .. }
15068        | NodeResponse::HistoryDiscovered { .. }
15069        | NodeResponse::HistoryLoaded { .. }
15070        | NodeResponse::ContextPackForSessionRecordExported { .. }
15071        | NodeResponse::ContextPackExported { .. }
15072        | NodeResponse::ContextPackForgotten { .. }
15073        | NodeResponse::DurableContextPackResolved { .. }
15074        | NodeResponse::ContextPackBytesRead { .. }
15075        | NodeResponse::SessionRecordForgotten { .. }
15076        | NodeResponse::WorkspaceRegistered { .. }
15077        | NodeResponse::StandaloneWorkspaceCreated { .. }
15078        | NodeResponse::WorkspaceUnregistered { .. }
15079        | NodeResponse::WorktreeCreated { .. }
15080        | NodeResponse::WorktreeRemoved { .. }
15081        | NodeResponse::Accepted
15082        | NodeResponse::ShuttingDown => {}
15083    }
15084}
15085
15086fn project_snapshot_history_for_wire(
15087    snapshot: &mut NodeSnapshot,
15088    include_history_metadata: bool,
15089) {
15090    for workspace in &mut snapshot.workspaces {
15091        for session in &mut workspace.sessions {
15092            if include_history_metadata {
15093                session.history.loaded = None;
15094            } else {
15095                session.history = Default::default();
15096            }
15097        }
15098    }
15099}
15100
15101fn project_event_history_for_wire(
15102    mut envelope: NodeEventEnvelope,
15103    include_history_metadata: bool,
15104) -> NodeEventEnvelope {
15105    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15106        for session in &mut workspace.sessions {
15107            if include_history_metadata {
15108                session.history.loaded = None;
15109            } else {
15110                session.history = Default::default();
15111            }
15112        }
15113    }
15114    envelope
15115}
15116
15117fn project_response_history_for_wire(
15118    reply: &mut ResponseEnvelope,
15119    include_history_metadata: bool,
15120) {
15121    let Ok(response) = reply.result.as_mut() else {
15122        return;
15123    };
15124    match response {
15125        NodeResponse::Snapshot { snapshot, .. } => {
15126            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15127        }
15128        NodeResponse::Resync {
15129            snapshot, events, ..
15130        } => {
15131            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15132            for event in events {
15133                *event = project_event_history_for_wire(
15134                    event.clone(),
15135                    include_history_metadata,
15136                );
15137            }
15138        }
15139        NodeResponse::WorkspaceRegistered { workspace }
15140        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15141        | NodeResponse::WorktreeCreated { workspace, .. } => {
15142            for session in &mut workspace.sessions {
15143                if include_history_metadata {
15144                    session.history.loaded = None;
15145                } else {
15146                    session.history = Default::default();
15147                }
15148            }
15149        }
15150        _ => {}
15151    }
15152}
15153
15154fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15155    for record in &mut snapshot.session_records {
15156        record.context_id = None;
15157        record.context = None;
15158        record.exported_context = None;
15159    }
15160}
15161
15162fn project_event_without_context_pack(
15163    mut envelope: NodeEventEnvelope,
15164) -> NodeEventEnvelope {
15165    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15166        record.context_id = None;
15167        record.context = None;
15168        record.exported_context = None;
15169    }
15170    envelope
15171}
15172
15173fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15174    let contains_context = match reply.result.as_ref() {
15175        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15176        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15177            receipt.spawn.context.is_some()
15178        }
15179        Ok(NodeResponse::HistoryDiscovered { .. })
15180        | Ok(NodeResponse::HistoryLoaded { .. })
15181        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15182        | Ok(NodeResponse::ContextPackExported { .. })
15183        | Ok(NodeResponse::ContextPackForgotten { .. })
15184        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15185        _ => false,
15186    };
15187    if contains_context {
15188        reply.result = Err(failure(
15189            NodeFailureCode::UnsupportedCapability,
15190            "history context packs were not negotiated",
15191        ));
15192        return;
15193    }
15194    let Ok(response) = reply.result.as_mut() else {
15195        return;
15196    };
15197    match response {
15198        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15199        NodeResponse::Resync {
15200            snapshot, events, ..
15201        } => {
15202            clear_snapshot_context_packs(snapshot);
15203            for event in events {
15204                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15205                    record.context_id = None;
15206                    record.context = None;
15207                    record.exported_context = None;
15208                }
15209            }
15210        }
15211        NodeResponse::SessionRecordUpdated { record }
15212        | NodeResponse::ProviderSessionIndexed { record }
15213        | NodeResponse::NativeSessionIndexed { record, .. }
15214        | NodeResponse::SessionRecordResumed { record, .. } => {
15215            record.context_id = None;
15216            record.context = None;
15217            record.exported_context = None;
15218        }
15219        _ => {}
15220    }
15221}
15222
15223fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15224    let contains_open_record = match reply.result.as_ref() {
15225        Ok(NodeResponse::SessionRecordUpdated { record })
15226        | Ok(NodeResponse::ProviderSessionIndexed { record })
15227        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15228            !provider_id_is_legacy(&record.provider)
15229        }
15230        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15231            !provider_id_is_legacy(&selection.route.provider)
15232                || !provider_id_is_legacy(&record.provider)
15233        }
15234        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15235            !provider_id_is_legacy(&receipt.provider)
15236        }
15237        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15238            !provider_id_is_legacy(&receipt.spawn.provider)
15239        }
15240        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15241        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15242            !provider_id_is_legacy(&route.provider)
15243        }
15244        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15245            !provider_id_is_legacy(&selection.route.provider)
15246        }
15247        _ => false,
15248    };
15249    if contains_open_record {
15250        reply.result = Err(failure(
15251            NodeFailureCode::UnsupportedCapability,
15252            "open provider IDs were not negotiated",
15253        ));
15254        return;
15255    }
15256    let Ok(response) = reply.result.as_mut() else {
15257        return;
15258    };
15259    match response {
15260        NodeResponse::Snapshot { snapshot, .. } => {
15261            project_snapshot_legacy_provider_ids(snapshot);
15262        }
15263        NodeResponse::Resync { snapshot, events, .. } => {
15264            project_snapshot_legacy_provider_ids(snapshot);
15265            *events = std::mem::take(events)
15266                .into_iter()
15267                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15268                .collect();
15269        }
15270        NodeResponse::WorkspaceRegistered { workspace }
15271        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15272        | NodeResponse::WorktreeCreated { workspace, .. } => {
15273            workspace
15274                .sessions
15275                .retain(|session| provider_id_is_legacy(&session.agent_id));
15276        }
15277        NodeResponse::Armed { .. }
15278        | NodeResponse::Spawned { .. }
15279        | NodeResponse::Activated { .. }
15280        | NodeResponse::Aborted { .. }
15281        | NodeResponse::ReplyChunkAccepted { .. }
15282        | NodeResponse::CallRejected { .. }
15283        | NodeResponse::DeliveryStageBegun { .. }
15284        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15285        | NodeResponse::DeliveryCommitted { .. }
15286        | NodeResponse::DeliveryStageAborted { .. }
15287        | NodeResponse::WorkspaceInspected { .. }
15288        | NodeResponse::HostDirectoriesBrowsed { .. }
15289        | NodeResponse::WorkspaceFileRead { .. }
15290        | NodeResponse::WorkspaceFileWritten { .. }
15291        | NodeResponse::WorkspaceFileCreated { .. }
15292        | NodeResponse::WorkspaceDirectoryCreated { .. }
15293        | NodeResponse::GitHistoryRead { .. }
15294        | NodeResponse::GitDiffRead { .. }
15295        | NodeResponse::Controller { .. }
15296        | NodeResponse::SpawnAccepted { .. }
15297        | NodeResponse::SpawnSpecAccepted { .. }
15298        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15299        | NodeResponse::ManagedWorktreeCleanup { .. }
15300        | NodeResponse::NativeSessionsCataloged { .. }
15301        | NodeResponse::NativeSessionsPaged { .. }
15302        | NodeResponse::NativeSessionPreviewed { .. }
15303        | NodeResponse::SessionRecordPreviewed { .. }
15304        | NodeResponse::HistoryDiscovered { .. }
15305        | NodeResponse::HistoryLoaded { .. }
15306        | NodeResponse::ContextPackForSessionRecordExported { .. }
15307        | NodeResponse::ContextPackExported { .. }
15308        | NodeResponse::ContextPackForgotten { .. }
15309        | NodeResponse::DurableContextPackResolved { .. }
15310        | NodeResponse::ContextPackBytesRead { .. }
15311        | NodeResponse::SessionRecordUpdated { .. }
15312        | NodeResponse::ProviderSessionIndexed { .. }
15313        | NodeResponse::NativeSessionIndexed { .. }
15314        | NodeResponse::SessionRecordResumed { .. }
15315        | NodeResponse::SessionRecordForgotten { .. }
15316        | NodeResponse::WorkspaceUnregistered { .. }
15317        | NodeResponse::WorktreeRemoved { .. }
15318        | NodeResponse::Accepted
15319        | NodeResponse::ShuttingDown => {}
15320    }
15321}
15322
15323fn project_event_legacy_provider_ids(
15324    shared: &NodeShared,
15325    mut envelope: NodeEventEnvelope,
15326) -> Option<NodeEventEnvelope> {
15327    let include = match &mut envelope.event {
15328        NodeEvent::HarnessMcpReadCall { .. } => true,
15329        NodeEvent::Control { address, .. }
15330        | NodeEvent::AgentStream { address, .. }
15331        | NodeEvent::TerminalFrame { address, .. } => shared
15332            .validate_address(address)
15333            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15334        NodeEvent::WorkspaceAdded { workspace } => {
15335            workspace
15336                .sessions
15337                .retain(|session| provider_id_is_legacy(&session.agent_id));
15338            true
15339        }
15340        NodeEvent::SessionRecordUpserted { record } => {
15341            provider_id_is_legacy(&record.provider)
15342        }
15343        NodeEvent::SessionRecordHistorySummarized { .. } => false,
15344        NodeEvent::SessionRecordRemoved { .. } => shared
15345            .history
15346            .lock()
15347            .unwrap_or_else(|poisoned| poisoned.into_inner())
15348            .removed_record_providers
15349            .get(&envelope.sequence)
15350            .is_some_and(provider_id_is_legacy),
15351        NodeEvent::ControllerChanged { .. }
15352        | NodeEvent::ManagedWorktreeUpserted { .. }
15353        | NodeEvent::ManagedWorktreeRemoved { .. }
15354        | NodeEvent::WorkspaceRemoved { .. }
15355        | NodeEvent::ResyncRequired { .. } => true,
15356    };
15357    include.then_some(envelope)
15358}
15359
15360fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15361    let Ok(response) = reply.result.as_mut() else {
15362        return;
15363    };
15364    match response {
15365        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15366            snapshot.provider_runtime_statuses.clear();
15367        }
15368        NodeResponse::Armed { .. }
15369        | NodeResponse::Spawned { .. }
15370        | NodeResponse::Activated { .. }
15371        | NodeResponse::Aborted { .. }
15372        | NodeResponse::ReplyChunkAccepted { .. }
15373        | NodeResponse::CallRejected { .. }
15374        | NodeResponse::DeliveryStageBegun { .. }
15375        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15376        | NodeResponse::DeliveryCommitted { .. }
15377        | NodeResponse::DeliveryStageAborted { .. }
15378        | NodeResponse::WorkspaceInspected { .. }
15379        | NodeResponse::HostDirectoriesBrowsed { .. }
15380        | NodeResponse::WorkspaceFileRead { .. }
15381        | NodeResponse::WorkspaceFileWritten { .. }
15382        | NodeResponse::WorkspaceFileCreated { .. }
15383        | NodeResponse::WorkspaceDirectoryCreated { .. }
15384        | NodeResponse::GitHistoryRead { .. }
15385        | NodeResponse::GitDiffRead { .. }
15386        | NodeResponse::Controller { .. }
15387        | NodeResponse::SpawnAccepted { .. }
15388        | NodeResponse::SpawnSpecAccepted { .. }
15389        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15390        | NodeResponse::ManagedWorktreeCleanup { .. }
15391        | NodeResponse::NativeSessionsCataloged { .. }
15392        | NodeResponse::NativeSessionsPaged { .. }
15393        | NodeResponse::NativeSessionPreviewed { .. }
15394        | NodeResponse::SessionRecordPreviewed { .. }
15395        | NodeResponse::HistoryDiscovered { .. }
15396        | NodeResponse::HistoryLoaded { .. }
15397        | NodeResponse::ContextPackForSessionRecordExported { .. }
15398        | NodeResponse::ContextPackExported { .. }
15399        | NodeResponse::ContextPackForgotten { .. }
15400        | NodeResponse::DurableContextPackResolved { .. }
15401        | NodeResponse::ContextPackBytesRead { .. }
15402        | NodeResponse::SessionRecordUpdated { .. }
15403        | NodeResponse::ProviderSessionIndexed { .. }
15404        | NodeResponse::NativeSessionIndexed { .. }
15405        | NodeResponse::SessionRecordResumed { .. }
15406        | NodeResponse::SessionRecordForgotten { .. }
15407        | NodeResponse::WorkspaceRegistered { .. }
15408        | NodeResponse::StandaloneWorkspaceCreated { .. }
15409        | NodeResponse::WorkspaceUnregistered { .. }
15410        | NodeResponse::WorktreeCreated { .. }
15411        | NodeResponse::WorktreeRemoved { .. }
15412        | NodeResponse::Accepted
15413        | NodeResponse::ShuttingDown => {}
15414    }
15415}
15416
15417struct ControllerReleaseGuard {
15418    shared: Arc<NodeShared>,
15419    connection_id: u64,
15420}
15421
15422impl Drop for ControllerReleaseGuard {
15423    fn drop(&mut self) {
15424        self.shared.release_controller(self.connection_id);
15425    }
15426}
15427
15428struct AbortTaskOnDrop(AbortHandle);
15429
15430impl Drop for AbortTaskOnDrop {
15431    fn drop(&mut self) {
15432        self.0.abort();
15433    }
15434}
15435
15436/// Reads an unsigned env-config override, following the pattern of every
15437/// other bounded knob in this module: missing/unparsable/zero falls back to
15438/// `default`, and the resolved value is always clamped to `max` so a bad
15439/// override cannot reopen a bound this budget exists to enforce.
15440fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15441    std::env::var(name)
15442        .ok()
15443        .and_then(|value| value.parse::<u64>().ok())
15444        .filter(|value| *value > 0)
15445        .unwrap_or(default)
15446        .min(max)
15447}
15448
15449fn workspace_inspection_time_budget_ms() -> u64 {
15450    env_config_bounded(
15451        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15452        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15453        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15454    )
15455}
15456
15457fn workspace_inspection_entry_cap() -> usize {
15458    env_config_bounded(
15459        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15460        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15461        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15462    ) as usize
15463}
15464
15465/// Tracks the shared walk-side budget across the whole recursive walk: a
15466/// wall-clock deadline (shared with the git phase that runs after the walk)
15467/// and a cap on directory entries visited, whether or not they were pushed
15468/// to the response. Cheap to check per entry — one `Instant::now()` and one
15469/// integer compare.
15470struct WorkspaceWalkBudget {
15471    deadline: Instant,
15472    entry_cap: usize,
15473    entries_visited: u64,
15474    time_budget_exceeded: bool,
15475    entry_cap_exceeded: bool,
15476}
15477
15478impl WorkspaceWalkBudget {
15479    fn new(deadline: Instant, entry_cap: usize) -> Self {
15480        Self {
15481            deadline,
15482            entry_cap,
15483            entries_visited: 0,
15484            time_budget_exceeded: false,
15485            entry_cap_exceeded: false,
15486        }
15487    }
15488
15489    fn is_exhausted(&self) -> bool {
15490        self.time_budget_exceeded || self.entry_cap_exceeded
15491    }
15492
15493    /// Records one visited directory entry (pushed to the response or not)
15494    /// and reports whether the walk must stop now.
15495    fn record_visit(&mut self) -> bool {
15496        self.entries_visited += 1;
15497        if self.entries_visited as usize >= self.entry_cap {
15498            self.entry_cap_exceeded = true;
15499        }
15500        if Instant::now() >= self.deadline {
15501            self.time_budget_exceeded = true;
15502        }
15503        self.is_exhausted()
15504    }
15505}
15506
15507fn collect_workspace_entries(
15508    root: &Path,
15509    deadline: Instant,
15510    entry_cap: usize,
15511) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15512    let mut entries = Vec::new();
15513    let mut truncated = false;
15514    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15515    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15516    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15517    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15518    // require strict global byte order over relative paths.
15519    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15520    (entries, truncated, budget)
15521}
15522
15523/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15524/// `inspect_workspace` response — `None` once the walk and git phase both
15525/// completed inside their shared budget.
15526fn workspace_inspection_truncation(
15527    walk_budget: &WorkspaceWalkBudget,
15528    git_time_budget_exceeded: bool,
15529    elapsed: Duration,
15530) -> Option<WorkspaceInspectionTruncationV1> {
15531    if !walk_budget.time_budget_exceeded
15532        && !walk_budget.entry_cap_exceeded
15533        && !git_time_budget_exceeded
15534    {
15535        return None;
15536    }
15537    Some(WorkspaceInspectionTruncationV1 {
15538        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15539        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15540        git_time_budget_exceeded,
15541        entries_visited: walk_budget.entries_visited,
15542        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15543    })
15544}
15545
15546fn walk_workspace_directory(
15547    directory: &Path,
15548    relative_directory: Option<&RepositoryPath>,
15549    depth: usize,
15550    entries: &mut Vec<WorkspaceEntry>,
15551    truncated: &mut bool,
15552    budget: &mut WorkspaceWalkBudget,
15553) {
15554    if budget.is_exhausted() {
15555        *truncated = true;
15556        return;
15557    }
15558    let read_dir = match std::fs::read_dir(directory) {
15559        Ok(read_dir) => read_dir,
15560        Err(_) => {
15561            *truncated = true;
15562            return;
15563        }
15564    };
15565    let mut children = Vec::new();
15566    for child in read_dir {
15567        match child {
15568            Ok(child) => children.push(child),
15569            Err(_) => *truncated = true,
15570        }
15571    }
15572    children.sort_by_key(|child| child.file_name());
15573    for child in children {
15574        if budget.record_visit() {
15575            *truncated = true;
15576            return;
15577        }
15578        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15579            *truncated = true;
15580            return;
15581        }
15582        let file_type = match child.file_type() {
15583            Ok(file_type) => file_type,
15584            Err(_) => {
15585                *truncated = true;
15586                continue;
15587            }
15588        };
15589        if file_type.is_symlink() {
15590            continue;
15591        }
15592        let name = match child.file_name().into_string() {
15593            Ok(name) => name,
15594            Err(_) => {
15595                *truncated = true;
15596                continue;
15597            }
15598        };
15599        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15600            continue;
15601        }
15602        let relative_path = match relative_directory {
15603            None => windows_repository_path(name),
15604            Some(parent) => parent
15605                .as_utf8()
15606                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15607        };
15608        let Some(relative_path) = relative_path else {
15609            *truncated = true;
15610            continue;
15611        };
15612        let kind = if file_type.is_dir() {
15613            WorkspaceEntryKind::Directory
15614        } else if file_type.is_file() {
15615            WorkspaceEntryKind::File
15616        } else {
15617            continue;
15618        };
15619        entries.push(WorkspaceEntry {
15620            relative_path: relative_path.clone(),
15621            kind,
15622        });
15623        if file_type.is_dir() {
15624            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15625                walk_workspace_directory(
15626                    &child.path(),
15627                    Some(&relative_path),
15628                    depth + 1,
15629                    entries,
15630                    truncated,
15631                    budget,
15632                );
15633            } else {
15634                *truncated = true;
15635            }
15636        }
15637    }
15638}
15639
15640fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15641    if value.contains('\\') {
15642        return None;
15643    }
15644    RepositoryPath::utf8(value).ok()
15645}
15646
15647fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15648    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15649}
15650
15651/// Excludes a directory from the workspace tree walk. The three literal
15652/// names are the common convention-based cases; the `CACHEDIR.TAG` check
15653/// beneath them catches every build-output directory regardless of name
15654/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
15655/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
15656/// directory without maintaining a name heuristic list). The name check
15657/// runs first and returns early so a directory rejected by name never pays
15658/// for the extra `CACHEDIR.TAG` stat.
15659fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15660    if name.eq_ignore_ascii_case(".git")
15661        || name.eq_ignore_ascii_case("target")
15662        || name.eq_ignore_ascii_case("node_modules")
15663    {
15664        return true;
15665    }
15666    path.join("CACHEDIR.TAG").is_file()
15667}
15668
15669fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15670    matches!(
15671        status,
15672        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15673    )
15674}
15675
15676#[derive(Clone, Debug, Eq, PartialEq)]
15677enum ContextPackRepositoryHead {
15678    NotRepository,
15679    Unborn,
15680    Commit(String),
15681}
15682
15683#[derive(Clone, Debug, Eq, PartialEq)]
15684struct ContextPackRepositoryObservation {
15685    head: ContextPackRepositoryHead,
15686    git: GitSnapshot,
15687    files: Vec<ContextPackRepositoryFileSource>,
15688}
15689
15690fn stable_context_pack_repository(
15691    first: ContextPackRepositoryObservation,
15692    second: ContextPackRepositoryObservation,
15693) -> Result<ContextPackRepository, NodeFailure> {
15694    if first != second {
15695        return Err(failure(
15696            NodeFailureCode::ContextPackMaterializationFailed,
15697            "context repository changed during bounded capture",
15698        ));
15699    }
15700    Ok(ContextPackRepository::from_sources(
15701        &second.git,
15702        second.files,
15703    ))
15704}
15705
15706/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
15707/// (and only once) the shared walk+git `deadline` has already passed. Every
15708/// remaining phase in `inspect_git_workspace` checks this immediately
15709/// before issuing its own git call, so a budget spent by the walk (or by an
15710/// earlier git call) skips the rest of the chain instead of paying for it
15711/// anyway.
15712fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15713    if Instant::now() < deadline {
15714        return false;
15715    }
15716    append_git_diagnostic(
15717        snapshot,
15718        &format!("{stage} skipped: workspace inspection time budget exhausted"),
15719    );
15720    true
15721}
15722
15723/// Deadline-aware sibling of `run_git_bounded`, used only by
15724/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
15725/// whatever remains of the shared `deadline` (never more than
15726/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
15727/// of the budget cannot still hand a full per-call timeout to every git
15728/// probe behind it. `inspect_git_workspace` already guards each call site
15729/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
15730/// passed yet here.
15731async fn run_git_bounded_with_deadline(
15732    root: &str,
15733    arguments: &[&str],
15734    output_limit: usize,
15735    deadline: Instant,
15736) -> io::Result<GitCommandOutput> {
15737    let remaining = deadline.checked_duration_since(Instant::now());
15738    let timeout_ms = remaining
15739        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15740        .unwrap_or(0)
15741        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15742    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15743}
15744
15745/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
15746/// `deadline` is the same absolute instant the preceding directory walk was
15747/// bounded by, so time already spent walking counts against what remains
15748/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
15749/// shared budget (not an individual per-call timeout) is what cut the git
15750/// phase short.
15751async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15752    let mut snapshot = GitSnapshot {
15753        is_repository: false,
15754        branch: None,
15755        status: Vec::new(),
15756        recent_commits: Vec::new(),
15757        worktrees: Vec::new(),
15758        managed_worktree: None,
15759        truncated: false,
15760        diagnostic: None,
15761    };
15762    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15763        return (snapshot, true);
15764    }
15765    let repository = match run_git_bounded_with_deadline(
15766        root,
15767        &["rev-parse", "--is-inside-work-tree"],
15768        4 * 1_024,
15769        deadline,
15770    )
15771    .await
15772    {
15773        Ok(output) => output,
15774        Err(error) => {
15775            append_git_diagnostic(
15776                &mut snapshot,
15777                &format!("git inspection unavailable: {error}"),
15778            );
15779            return (snapshot, false);
15780        }
15781    };
15782    snapshot.truncated |= repository.truncated;
15783    if repository.timed_out {
15784        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15785        return (snapshot, false);
15786    }
15787    if !repository.success
15788        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15789    {
15790        let stderr = String::from_utf8_lossy(&repository.stderr);
15791        if !stderr.to_ascii_lowercase().contains("not a git repository") {
15792            append_git_diagnostic(&mut snapshot, stderr.trim());
15793        }
15794        return (snapshot, false);
15795    }
15796    snapshot.is_repository = true;
15797
15798    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15799        return (snapshot, true);
15800    }
15801    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15802        Ok(output) => {
15803            snapshot.truncated |= output.truncated;
15804            if output.timed_out {
15805                append_git_diagnostic(&mut snapshot, "git branch query timed out");
15806            } else if output.success {
15807                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15808                if !branch.is_empty() {
15809                    snapshot.branch = Some(truncate_git_field(
15810                        &branch,
15811                        MAX_REPOSITORY_PATH_BYTES,
15812                        &mut snapshot.truncated,
15813                    ));
15814                }
15815            }
15816        }
15817        Err(error) => append_git_diagnostic(
15818            &mut snapshot,
15819            &format!("git branch query failed: {error}"),
15820        ),
15821    }
15822    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15823        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15824            snapshot.truncated |= output.truncated;
15825            if output.success && !output.timed_out {
15826                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15827                if !head.is_empty() {
15828                    snapshot.branch = Some(format!("detached:{head}"));
15829                }
15830            }
15831        }
15832    }
15833
15834    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15835        return (snapshot, true);
15836    }
15837    match run_git_bounded_with_deadline(
15838        root,
15839        &[
15840            "status",
15841            "--porcelain=v1",
15842            "-z",
15843            "--renames",
15844            "--untracked-files=normal",
15845            "--",
15846            ".",
15847        ],
15848        GIT_OUTPUT_MAX_BYTES,
15849        deadline,
15850    )
15851    .await
15852    {
15853        Ok(output) => {
15854            snapshot.truncated |= output.truncated;
15855            if output.timed_out {
15856                append_git_diagnostic(&mut snapshot, "git status timed out");
15857            } else if output.success {
15858                parse_git_status(&output.stdout, &mut snapshot);
15859            } else {
15860                append_git_diagnostic(
15861                    &mut snapshot,
15862                    String::from_utf8_lossy(&output.stderr).trim(),
15863                );
15864            }
15865        }
15866        Err(error) => append_git_diagnostic(
15867            &mut snapshot,
15868            &format!("git status failed: {error}"),
15869        ),
15870    }
15871
15872    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15873        return (snapshot, true);
15874    }
15875    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15876    match run_git_bounded_with_deadline(
15877        root,
15878        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15879        16 * 1_024,
15880        deadline,
15881    )
15882    .await
15883    {
15884        Ok(output) => {
15885            snapshot.truncated |= output.truncated;
15886            if output.timed_out {
15887                append_git_diagnostic(&mut snapshot, "git log timed out");
15888            } else if output.success {
15889                parse_git_commits(&output.stdout, &mut snapshot);
15890            } else {
15891                let stderr = String::from_utf8_lossy(&output.stderr);
15892                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15893                    append_git_diagnostic(&mut snapshot, stderr.trim());
15894                }
15895            }
15896        }
15897        Err(error) => append_git_diagnostic(
15898            &mut snapshot,
15899            &format!("git log failed: {error}"),
15900        ),
15901    }
15902
15903    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15904        return (snapshot, true);
15905    }
15906    match list_git_worktrees_with_deadline(root, deadline).await {
15907        Ok(worktrees) => {
15908            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15909        }
15910        Err(error) => append_git_diagnostic(
15911            &mut snapshot,
15912            &format!("git worktree list failed: {}", error.message),
15913        ),
15914    }
15915    (snapshot, false)
15916}
15917
15918fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15919    let mut remaining = output;
15920    while !remaining.is_empty() {
15921        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15922            snapshot.truncated = true;
15923            break;
15924        }
15925        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15926            snapshot.truncated = true;
15927            break;
15928        };
15929        let record = &remaining[..record_end];
15930        remaining = &remaining[record_end + 1..];
15931        let has_previous_path = record
15932            .get(..2)
15933            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15934        let previous_path_bytes = if has_previous_path {
15935            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15936                snapshot.truncated = true;
15937                break;
15938            };
15939            let previous = &remaining[..previous_end];
15940            remaining = &remaining[previous_end + 1..];
15941            Some(previous)
15942        } else {
15943            None
15944        };
15945        if record.len() < 4
15946            || record[2] != b' '
15947            || !is_git_status_code(record[0])
15948            || !is_git_status_code(record[1])
15949        {
15950            snapshot.truncated = true;
15951            continue;
15952        }
15953        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15954            snapshot.truncated = true;
15955            continue;
15956        };
15957        let previous_path = match previous_path_bytes {
15958            Some(previous) => {
15959                let Some(previous) = windows_repository_path_from_bytes(previous) else {
15960                    snapshot.truncated = true;
15961                    continue;
15962                };
15963                Some(previous)
15964            }
15965            None => None,
15966        };
15967        snapshot.status.push(GitStatusEntry {
15968            index_status: (record[0] as char).to_string(),
15969            worktree_status: (record[1] as char).to_string(),
15970            path,
15971            previous_path,
15972        });
15973    }
15974}
15975
15976fn is_git_status_code(byte: u8) -> bool {
15977    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15978}
15979
15980fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15981    for line in String::from_utf8_lossy(output).lines() {
15982        let Some((id, summary)) = line.split_once('\u{1f}') else {
15983            continue;
15984        };
15985        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15986            snapshot.truncated = true;
15987            break;
15988        }
15989        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15990        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15991        snapshot.recent_commits.push(GitCommitSummary { id, summary });
15992    }
15993}
15994
15995fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15996    if value.len() <= max_bytes {
15997        return value.to_owned();
15998    }
15999    *truncated = true;
16000    let mut boundary = max_bytes;
16001    while boundary > 0 && !value.is_char_boundary(boundary) {
16002        boundary -= 1;
16003    }
16004    value[..boundary].to_owned()
16005}
16006
16007fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16008    if message.is_empty() {
16009        return;
16010    }
16011    let normalized = message
16012        .chars()
16013        .map(|character| if character.is_control() { ' ' } else { character })
16014        .collect::<String>();
16015    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16016    if !diagnostic.is_empty() {
16017        diagnostic.push_str("; ");
16018    }
16019    diagnostic.push_str(normalized.trim());
16020    snapshot.diagnostic = Some(truncate_git_field(
16021        &diagnostic,
16022        GIT_DIAGNOSTIC_MAX_BYTES,
16023        &mut snapshot.truncated,
16024    ));
16025}
16026
16027async fn run_git_bounded(
16028    root: &str,
16029    arguments: &[&str],
16030    output_limit: usize,
16031) -> io::Result<GitCommandOutput> {
16032    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16033}
16034
16035async fn read_git_history_bounded(
16036    root: &str,
16037    path: Option<&RepositoryPath>,
16038    before: Option<&GitObjectId>,
16039    limit: u16,
16040) -> Result<GitHistoryPage, NodeFailure> {
16041    let path = path
16042        .map(|path| {
16043            path.as_utf8().ok_or_else(|| {
16044                failure(
16045                    NodeFailureCode::InvalidRepositoryPath,
16046                    "git history path must use UTF-8",
16047                )
16048            })
16049        })
16050        .transpose()?
16051        .unwrap_or(".");
16052    let count = (usize::from(limit) + 1).to_string();
16053    let start = before
16054        .map(|revision| format!("{}^", revision.as_str()))
16055        .unwrap_or_else(|| "HEAD".to_owned());
16056    let arguments = [
16057        "--no-pager",
16058        "log",
16059        "--no-decorate",
16060        "--date=iso-strict",
16061        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16062        "-n",
16063        count.as_str(),
16064        start.as_str(),
16065        "--",
16066        path,
16067    ];
16068    let output = run_git_bounded(root, &arguments, 256 * 1_024)
16069        .await
16070        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16071    if output.timed_out {
16072        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16073    }
16074    if !output.success {
16075        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16076    }
16077    let mut commits = parse_git_history(&output.stdout);
16078    let has_more = commits.len() > usize::from(limit) || output.truncated;
16079    commits.truncate(usize::from(limit));
16080    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16081    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16082}
16083
16084fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16085    String::from_utf8_lossy(output)
16086        .split('\u{1e}')
16087        .filter_map(|record| {
16088            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16089            if fields.len() != 9 {
16090                return None;
16091            }
16092            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16093            let parents = if fields[1].is_empty() {
16094                Vec::new()
16095            } else {
16096                fields[1]
16097                    .split(' ')
16098                    .map(|value| GitObjectId::new(value.to_owned()))
16099                    .collect::<Result<Vec<_>, _>>()
16100                    .ok()?
16101            };
16102            Some(GitCommitDetails {
16103                id,
16104                parents,
16105                subject: bounded_git_text(fields[2], 512),
16106                author_name: bounded_git_text(fields[3], 256),
16107                author_email: bounded_git_text(fields[4], 320),
16108                authored_at: bounded_git_text(fields[5], 64),
16109                committer_name: bounded_git_text(fields[6], 256),
16110                committer_email: bounded_git_text(fields[7], 320),
16111                committed_at: bounded_git_text(fields[8], 64),
16112                signature_status: GitSignatureStatus::NoSignature,
16113                signer: None,
16114            })
16115        })
16116        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16117        .collect()
16118}
16119
16120fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16121    let normalized = value
16122        .chars()
16123        .map(|character| if character.is_control() { ' ' } else { character })
16124        .collect::<String>();
16125    if normalized.len() <= max_bytes {
16126        return normalized;
16127    }
16128    let mut boundary = max_bytes;
16129    while boundary > 0 && !normalized.is_char_boundary(boundary) {
16130        boundary -= 1;
16131    }
16132    normalized[..boundary].to_owned()
16133}
16134
16135async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16136    let path = request.path.as_ref().map(|path| {
16137        path.as_utf8()
16138            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16139    }).transpose()?;
16140    let mut arguments = match &request.mode {
16141        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16142        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16143        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16144    };
16145    if let GitDiffMode::Commit { revision } = &request.mode {
16146        arguments.push(revision.as_str());
16147    }
16148    arguments.push("--");
16149    if let Some(path) = path {
16150        arguments.push(path);
16151    }
16152    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16153        .await
16154        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16155    if output.timed_out {
16156        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16157    }
16158    if !output.success {
16159        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16160    }
16161    Ok(GitDiff {
16162        mode: request.mode,
16163        path: request.path,
16164        text: String::from_utf8_lossy(&output.stdout).into_owned(),
16165        truncated: output.truncated,
16166    })
16167}
16168
16169async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16170    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16171    ResponseEnvelope { request_id: envelope.request_id, result }
16172}
16173
16174async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16175    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16176        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16177    }
16178    let read_only = matches!(
16179        &request,
16180        NodeRequest::Snapshot
16181            | NodeRequest::Resync { .. }
16182            | NodeRequest::BrowseHostDirectories { .. }
16183            | NodeRequest::InspectWorkspace { .. }
16184            | NodeRequest::ReadWorkspaceFile { .. }
16185            | NodeRequest::ReadGitHistory { .. }
16186            | NodeRequest::ReadGitDiff { .. }
16187            | NodeRequest::CatalogNativeSessions { .. }
16188            | NodeRequest::PageNativeSessions { .. }
16189            | NodeRequest::PreviewNativeSession { .. }
16190            | NodeRequest::PreviewSessionRecord { .. }
16191    );
16192    let _mutation_guard = if read_only {
16193        None
16194    } else {
16195        let guard = shared.mutation_gate.lock().await;
16196        if shared.shutdown.load(Ordering::Acquire) {
16197            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16198        }
16199        Some(guard)
16200    };
16201    match request {
16202        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16203            event_sequence: shared.current_sequence(),
16204            controller: shared.controller_state(),
16205            snapshot: shared.snapshot(),
16206        }),
16207        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16208        NodeRequest::ArmHarnessMcpReservation {
16209            reservation_id,
16210            activation_digest,
16211            spawn_spec,
16212            launch,
16213            expires_at_unix_ms,
16214        } => {
16215            shared.require_controller(connection_id, role)?;
16216            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16217                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16218            })?;
16219            let expires_at_unix_ms = registry
16220                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16221                .await
16222                .map_err(harness_mcp_failure)?;
16223            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16224        }
16225        NodeRequest::SpawnSpecWithHarnessMcp {
16226            reservation_id,
16227            activation_digest,
16228            spec,
16229            deadline_unix_ms,
16230        } => {
16231            shared.require_controller(connection_id, role)?;
16232            let receipt = shared
16233                .spawn_from_spec_with_harness_mcp(
16234                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16235                )
16236                .await?;
16237            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16238        }
16239        NodeRequest::ActivateHarnessMcpReservation {
16240            reservation_id,
16241            activation_digest,
16242            record_id,
16243            session,
16244        } => {
16245            shared.require_controller(connection_id, role)?;
16246            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16247                current.instance_id == session.session.instance_id
16248                    && current.generation == session.session.generation
16249            }).and_then(|current| current.process_id).ok_or_else(|| {
16250                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16251            })?;
16252            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16253                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16254            })?.activate(
16255                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16256            ).map_err(harness_mcp_failure)?;
16257            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16258        }
16259        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16260            shared.require_controller(connection_id, role)?;
16261            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16262                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16263            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16264            if let Some(instance_id) = instance_id {
16265                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16266            }
16267            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16268        }
16269        NodeRequest::PutHarnessMcpReplyChunk {
16270            reservation_id, activation_digest, record_id, session, call_id,
16271            offset, final_chunk, chunk_hex,
16272        } => {
16273            shared.require_controller(connection_id, role)?;
16274            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16275                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16276            })?.put_reply_chunk(
16277                &reservation_id, &activation_digest, &record_id, &session, &call_id,
16278                offset, final_chunk, &chunk_hex,
16279            ).map_err(harness_mcp_failure)?;
16280            Ok(NodeResponse::ReplyChunkAccepted {
16281                reservation_id, activation_digest, record_id, session, call_id,
16282                next_offset, completed,
16283            })
16284        }
16285        NodeRequest::RejectHarnessMcpCall {
16286            reservation_id, activation_digest, record_id, session, call_id, reason,
16287        } => {
16288            shared.require_controller(connection_id, role)?;
16289            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16290                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16291            })?.reject_call(
16292                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16293            ).map_err(harness_mcp_failure)?;
16294            Ok(NodeResponse::CallRejected {
16295                reservation_id, activation_digest, record_id, session, call_id,
16296            })
16297        }
16298        NodeRequest::BeginDeliveryStage { manifest } => {
16299            shared.require_controller(connection_id, role)?;
16300            shared.begin_delivery_stage(manifest)
16301        }
16302        NodeRequest::PutDeliveryBlobChunk {
16303            stage_id,
16304            blob_digest,
16305            offset,
16306            chunk_hex,
16307        } => {
16308            shared.require_controller(connection_id, role)?;
16309            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16310        }
16311        NodeRequest::CommitDeliveryStage { stage_id } => {
16312            shared.require_controller(connection_id, role)?;
16313            let receipt = shared.commit_delivery_stage(stage_id)?;
16314            Ok(NodeResponse::DeliveryCommitted { receipt })
16315        }
16316        NodeRequest::AbortDeliveryStage { stage_id } => {
16317            shared.require_controller(connection_id, role)?;
16318            shared.abort_delivery_stage(&stage_id)?;
16319            Ok(NodeResponse::DeliveryStageAborted { stage_id })
16320        }
16321        NodeRequest::BrowseHostDirectories { directory, after } => {
16322            let listing = shared.browse_host_directories(directory, after).await?;
16323            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16324        }
16325        NodeRequest::InspectWorkspace { workspace_id } => {
16326            let inspection = shared.inspect_workspace(workspace_id).await?;
16327            Ok(NodeResponse::WorkspaceInspected { inspection })
16328        }
16329        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16330            let file = shared.read_workspace_file(workspace_id, path).await?;
16331            Ok(NodeResponse::WorkspaceFileRead { file })
16332        }
16333        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16334            shared.require_controller(connection_id, role)?;
16335            let file = shared
16336                .write_workspace_file(workspace_id, path, expected_revision, text)
16337                .await?;
16338            Ok(NodeResponse::WorkspaceFileWritten { file })
16339        }
16340        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16341            shared.require_controller(connection_id, role)?;
16342            let file = shared.create_workspace_file(workspace_id, path).await?;
16343            Ok(NodeResponse::WorkspaceFileCreated { file })
16344        }
16345        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16346            shared.require_controller(connection_id, role)?;
16347            let entry = shared
16348                .create_workspace_directory(workspace_id.clone(), path)
16349                .await?;
16350            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16351        }
16352        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16353            if role != ClientRole::Operator {
16354                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16355            }
16356            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16357                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16358            }
16359            let page = shared
16360                .read_git_history(workspace_id.clone(), path, before, limit)
16361                .await?;
16362            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16363        }
16364        NodeRequest::ReadGitDiff { workspace_id, request } => {
16365            if role != ClientRole::Operator {
16366                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16367            }
16368            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16369            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16370        }
16371        NodeRequest::CatalogNativeSessions { route, limit } => {
16372            if role != ClientRole::Operator {
16373                return Err(failure(
16374                    NodeFailureCode::ObserverReadOnly,
16375                    "native session catalog requires an operator connection",
16376                ));
16377            }
16378            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16379                return Err(failure(
16380                    NodeFailureCode::InvalidRequest,
16381                    "native session catalog limit is invalid",
16382                ));
16383            }
16384            let (entries, summary) = shared
16385                .catalog_native_sessions(route.clone(), limit)
16386                .await?;
16387            Ok(NodeResponse::NativeSessionsCataloged {
16388                route,
16389                entries,
16390                summary: Some(summary),
16391            })
16392        }
16393        NodeRequest::PageNativeSessions {
16394            route,
16395            window,
16396            catalog_revision,
16397            recent_cutoff_unix_ms,
16398            after_selection_id,
16399            limit,
16400        } => {
16401            if role != ClientRole::Operator {
16402                return Err(failure(
16403                    NodeFailureCode::ObserverReadOnly,
16404                    "native session catalog paging requires an operator connection",
16405                ));
16406            }
16407            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16408                || after_selection_id
16409                    .as_deref()
16410                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16411            {
16412                return Err(failure(
16413                    NodeFailureCode::InvalidRequest,
16414                    "native session catalog page request is invalid",
16415                ));
16416            }
16417            let page = shared
16418                .page_native_sessions(
16419                    route.clone(),
16420                    window,
16421                    catalog_revision,
16422                    recent_cutoff_unix_ms,
16423                    after_selection_id,
16424                    limit,
16425                )
16426                .await?;
16427            Ok(NodeResponse::NativeSessionsPaged {
16428                route,
16429                page,
16430            })
16431        }
16432        NodeRequest::PreviewNativeSession {
16433            selection,
16434            message_limit,
16435        } => {
16436            if role != ClientRole::Operator {
16437                return Err(failure(
16438                    NodeFailureCode::ObserverReadOnly,
16439                    "native session preview requires an operator connection",
16440                ));
16441            }
16442            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16443                .contains(&message_limit)
16444                || selection.validate().is_err()
16445            {
16446                return Err(failure(
16447                    NodeFailureCode::InvalidRequest,
16448                    "native session preview request is invalid",
16449                ));
16450            }
16451            let preview = shared
16452                .preview_native_session(selection.clone(), message_limit)
16453                .await?;
16454            Ok(NodeResponse::NativeSessionPreviewed {
16455                selection,
16456                preview,
16457            })
16458        }
16459        NodeRequest::IndexNativeSession {
16460            selection,
16461            display_name,
16462        } => {
16463            shared.require_controller(connection_id, role)?;
16464            let record = shared
16465                .index_native_session(selection.clone(), display_name)
16466                .await?;
16467            Ok(NodeResponse::NativeSessionIndexed { selection, record })
16468        }
16469        NodeRequest::PreviewSessionRecord {
16470            record_id,
16471            message_limit,
16472        } => {
16473            if role != ClientRole::Operator {
16474                return Err(failure(
16475                    NodeFailureCode::ObserverReadOnly,
16476                    "native session preview requires an operator connection",
16477                ));
16478            }
16479            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16480                .contains(&message_limit)
16481            {
16482                return Err(failure(
16483                    NodeFailureCode::InvalidRequest,
16484                    "native session preview request is invalid",
16485                ));
16486            }
16487            let record = shared.record(&record_id)?;
16488            let working_directory = shared.workspace_root(&record.workspace_id)?;
16489            if !platform::roots_equal(
16490                &working_directory,
16491                windows_path_text(&record.canonical_root),
16492            ) {
16493                return Err(failure(
16494                    NodeFailureCode::SessionRecordConflict,
16495                    "managed session workspace root changed; refusing to preview another directory",
16496                ));
16497            }
16498            let identity = record.provider_session.clone().ok_or_else(|| {
16499                failure(
16500                    NodeFailureCode::BackendOperationFailed,
16501                    "native session preview is unavailable for this session record",
16502                )
16503            })?;
16504            if identity.key != ProviderSessionKey::SessionId {
16505                return Err(failure(
16506                    NodeFailureCode::BackendOperationFailed,
16507                    "native session preview is unavailable for this session identity",
16508                ));
16509            }
16510            let preview = shared
16511                .preview_native_session_by_session_id(
16512                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16513                )
16514                .await?;
16515            if preview.session_id != identity.id {
16516                return Err(failure(
16517                    NodeFailureCode::SessionRecordConflict,
16518                    "native session preview identity changed while loading",
16519                ));
16520            }
16521            shared.revalidate_session_record_preview(&record, &identity)?;
16522            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16523            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16524                record_id: record_id.clone(),
16525                summary: SessionHistorySummaryV1::from(&preview),
16526            });
16527            Ok(NodeResponse::SessionRecordPreviewed {
16528                record_id,
16529                preview,
16530            })
16531        }
16532        NodeRequest::AcquireController { lease_ms } => {
16533            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16534            Ok(NodeResponse::Controller { controller: Some(controller) })
16535        }
16536        NodeRequest::ReleaseController => {
16537            shared.release_controller(connection_id);
16538            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16539        }
16540        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16541            shared.require_controller(connection_id, role)?;
16542            let root = require_windows_path(root)?;
16543            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16544            let workspace = shared.register_workspace(workspace_id, root).await?;
16545            Ok(NodeResponse::WorkspaceRegistered { workspace })
16546        }
16547        NodeRequest::CreateStandaloneWorkspace {
16548            workspace_id,
16549            root,
16550            initial_branch,
16551        } => {
16552            shared.require_controller(connection_id, role)?;
16553            let root = require_windows_path(root)?;
16554            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16555            let workspace = shared
16556                .create_standalone_workspace(workspace_id, root, initial_branch)
16557                .await?;
16558            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16559        }
16560        NodeRequest::UnregisterWorkspace { workspace_id } => {
16561            shared.require_controller(connection_id, role)?;
16562            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16563            shared.unregister_workspace(&workspace_id)?;
16564            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16565        }
16566        NodeRequest::CreateWorktree {
16567            source_workspace_id,
16568            workspace_id,
16569            target_root,
16570            branch,
16571            base,
16572        } => {
16573            shared.require_controller(connection_id, role)?;
16574            let target_root = require_windows_path(target_root)?;
16575            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16576            let (worktree, workspace) = shared
16577                .create_worktree(
16578                    source_workspace_id,
16579                    workspace_id,
16580                    target_root,
16581                    branch,
16582                    base,
16583                )
16584                .await?;
16585            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16586        }
16587        NodeRequest::RemoveWorktree {
16588            source_workspace_id,
16589            target_root,
16590        } => {
16591            shared.require_controller(connection_id, role)?;
16592            let native_target_root = require_windows_path(target_root.clone())?;
16593            shared.reject_managed_reservation(None, Some(&native_target_root))?;
16594            let workspace_id = shared
16595                .remove_worktree(source_workspace_id, native_target_root)
16596                .await?;
16597            Ok(NodeResponse::WorktreeRemoved {
16598                target_root,
16599                workspace_id,
16600            })
16601        }
16602        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16603            shared.require_controller(connection_id, role)?;
16604            if !terminal_size.is_valid() {
16605                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16606            }
16607            if let Some(prompt) = initial_prompt.as_deref() {
16608                validate_node_text("spawn initial prompt", prompt)?;
16609            }
16610            match shared
16611                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16612                .await
16613            {
16614                Ok(session) => {
16615                    shared.mark_spawn_provisional(session.clone());
16616                    Ok(NodeResponse::SpawnAccepted { session })
16617                }
16618                Err(err) => {
16619                    tracing::warn!(
16620                        %provider,
16621                        %workspace_id,
16622                        ?mode,
16623                        code = ?err.code,
16624                        message = %err.message,
16625                        "session spawn refused"
16626                    );
16627                    Err(err)
16628                }
16629            }
16630        }
16631        NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16632            shared.require_controller(connection_id, role)?;
16633            if !terminal_size.is_valid() {
16634                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16635            }
16636            if let Some(prompt) = initial_prompt.as_deref() {
16637                validate_node_text("spawn initial prompt", prompt)?;
16638            }
16639            let plan = backend_paths::plan_launch_options(
16640                provider.as_str(),
16641                mode,
16642                options.model.is_some(),
16643                options.effort.is_some(),
16644            )
16645            .map_err(|r| {
16646                tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16647                failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16648            })?;
16649            let selection = spawn_selection(&options)?;
16650            let pty_selection = match plan {
16651                backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16652                _ => None,
16653            };
16654            let spawned = SPAWN_SESSION_OPTIONS
16655                .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16656                .await;
16657            match spawned {
16658                Ok(session) => {
16659                    if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16660                        shared
16661                            .pending_acp_options
16662                            .lock()
16663                            .unwrap_or_else(|p| p.into_inner())
16664                            .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16665                    }
16666                    shared.mark_spawn_provisional(session.clone());
16667                    tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
16668                    Ok(NodeResponse::SpawnAccepted { session })
16669                }
16670                Err(err) => {
16671                    tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
16672                    Err(err)
16673                }
16674            }
16675        }
16676        NodeRequest::SpawnSpec { spec } => {
16677            shared.require_controller(connection_id, role)?;
16678            let receipt = shared.spawn_from_spec(spec).await?;
16679            Ok(NodeResponse::SpawnSpecAccepted { receipt })
16680        }
16681        NodeRequest::SpawnManagedWorktree { request } => {
16682            shared.require_controller(connection_id, role)?;
16683            let receipt = shared.spawn_managed_worktree(request).await?;
16684            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16685        }
16686        NodeRequest::SpawnManagedWorktreeV2 { request } => {
16687            shared.require_controller(connection_id, role)?;
16688            let receipt = shared.spawn_managed_worktree_v2(request).await?;
16689            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16690        }
16691        NodeRequest::CleanupManagedWorktree { lease_id } => {
16692            shared.require_controller(connection_id, role)?;
16693            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16694            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16695        }
16696        NodeRequest::DiscoverHistory { session, limit } => {
16697            controlled_session(shared, connection_id, role, &session)?;
16698            let candidates = shared.discover_history(&session, limit).await?;
16699            Ok(NodeResponse::HistoryDiscovered {
16700                session,
16701                candidates,
16702            })
16703        }
16704        NodeRequest::LoadHistory {
16705            session,
16706            candidate_id,
16707        } => {
16708            controlled_session(shared, connection_id, role, &session)?;
16709            let loaded = shared
16710                .load_history(&session, candidate_id)
16711                .await?;
16712            Ok(NodeResponse::HistoryLoaded {
16713                session,
16714                session_id: loaded.session_id,
16715                message_count: loaded.message_count,
16716                completed_turn_count: loaded.completed_turn_count,
16717            })
16718        }
16719        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16720            shared.require_controller(connection_id, role)?;
16721            let context = shared
16722                .export_context_pack_for_session_record(&record_id, &session, false)
16723                .await?;
16724            Ok(NodeResponse::ContextPackForSessionRecordExported {
16725                record_id,
16726                session,
16727                context,
16728            })
16729        }
16730        NodeRequest::ExportContextPack { session } => {
16731            controlled_session(shared, connection_id, role, &session)?;
16732            let context = shared.export_context_pack(&session).await?;
16733            Ok(NodeResponse::ContextPackExported { context })
16734        }
16735        NodeRequest::ForgetContextPack { context_id } => {
16736            shared.require_controller(connection_id, role)?;
16737            shared.forget_context_pack(&context_id)?;
16738            Ok(NodeResponse::ContextPackForgotten { context_id })
16739        }
16740        NodeRequest::ResolveDurableContextPack { context_id } => {
16741            // Read-only, idempotent, no controller lease required: this lets
16742            // a caller preflight "is this durably exported pack still here"
16743            // without holding the Node's mutation controller.
16744            let context = shared
16745                .context_catalog
16746                .read()
16747                .unwrap_or_else(|poisoned| poisoned.into_inner())
16748                .get(&context_id)
16749                .map(|pack| pack.receipt().clone())
16750                .ok_or_else(|| {
16751                    failure(
16752                        NodeFailureCode::UnknownContextPack,
16753                        "context pack is not present",
16754                    )
16755                })?;
16756            Ok(NodeResponse::DurableContextPackResolved { context })
16757        }
16758        NodeRequest::ReadContextPack { digest } => {
16759            // Read-only fetch of the pack's own bytes, keyed by content digest
16760            // rather than the caller's `SpawnContextId`: same no-controller-lease
16761            // rationale as `ResolveDurableContextPack` above, one step further
16762            // down the same catalog.
16763            let (id, byte_len, bytes) = shared
16764                .context_catalog
16765                .read()
16766                .unwrap_or_else(|poisoned| poisoned.into_inner())
16767                .get_by_digest(&digest)
16768                .map(|pack| {
16769                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16770                })
16771                .ok_or_else(|| {
16772                    failure(
16773                        NodeFailureCode::UnknownContextPack,
16774                        &format!("context pack {digest} not on this node"),
16775                    )
16776                })?;
16777            tracing::debug!(
16778                digest = digest.as_str(),
16779                byte_len,
16780                "read context pack bytes",
16781            );
16782            Ok(NodeResponse::ContextPackBytesRead {
16783                pack: ContextPackBytesRead {
16784                    digest,
16785                    id,
16786                    byte_len,
16787                    bytes,
16788                },
16789            })
16790        }
16791        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16792            let provider = controlled_session(shared, connection_id, role, &session)?;
16793            if let Some(prompt) = initial_prompt.as_deref() {
16794                validate_node_text("resume initial prompt", prompt)?;
16795            }
16796            let runtime_requirement = if initial_prompt.is_some() {
16797                ProviderRuntimeRequirement::ResumeWithPrompt
16798            } else {
16799                ProviderRuntimeRequirement::Resume
16800            };
16801            shared.require_session_runtime_policy(&session, runtime_requirement)?;
16802            let runtime_policy = shared
16803                .admit_provider_runtime(&provider, runtime_requirement)
16804                .await?;
16805            let working_directory = shared.bound_workspace_root(&session)?;
16806            let request = ResumeLaunchRequest {
16807                working_directory,
16808                terminal_size,
16809                initial_prompt,
16810            };
16811            request
16812                .validate()
16813                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16814            let command = shared.prepare_command(ControlCommand::Resume {
16815                instance_id: session.session.instance_id,
16816                target: ResumeTarget::CurrentProvider,
16817                runtime_policy,
16818                request,
16819            });
16820            shared.arm_resume(&session, command.id, runtime_policy)?;
16821            let dispatch = shared.dispatch_envelope(command);
16822            if let Err(error) = dispatch {
16823                shared.clear_armed_resume(&session);
16824                return Err(error);
16825            }
16826            Ok(NodeResponse::Accepted)
16827        }
16828        NodeRequest::RenameSessionRecord {
16829            record_id,
16830            display_name,
16831        } => {
16832            shared.require_controller(connection_id, role)?;
16833            let record = shared.rename_session_record(&record_id, display_name)?;
16834            Ok(NodeResponse::SessionRecordUpdated { record })
16835        }
16836        NodeRequest::SetSessionTask {
16837            record_id,
16838            expected_revision,
16839            target,
16840        } => {
16841            shared.require_controller(connection_id, role)?;
16842            let record = shared.set_session_task(&record_id, expected_revision, target)?;
16843            Ok(NodeResponse::SessionRecordUpdated { record })
16844        }
16845        NodeRequest::IndexProviderSession {
16846            workspace_id,
16847            provider,
16848            identity,
16849            display_name,
16850        } => {
16851            shared.require_controller(connection_id, role)?;
16852            let record = shared.index_provider_session(
16853                workspace_id,
16854                provider,
16855                identity,
16856                display_name,
16857            )?;
16858            Ok(NodeResponse::ProviderSessionIndexed { record })
16859        }
16860        NodeRequest::ResumeSessionRecord {
16861            record_id,
16862            terminal_size,
16863            initial_prompt,
16864        } => {
16865            shared.require_controller(connection_id, role)?;
16866            if let Some(prompt) = initial_prompt.as_deref() {
16867                validate_node_text("managed resume initial prompt", prompt)?;
16868            }
16869            let (record, session) = shared
16870                .resume_session_record(
16871                    &record_id,
16872                    terminal_size,
16873                    initial_prompt,
16874                )
16875                .await?;
16876            Ok(NodeResponse::SessionRecordResumed { record, session })
16877        }
16878        NodeRequest::ForgetSessionRecord { record_id } => {
16879            shared.require_controller(connection_id, role)?;
16880            shared.forget_session_record(&record_id).await?;
16881            Ok(NodeResponse::SessionRecordForgotten { record_id })
16882        }
16883        NodeRequest::Prompt { session, text } => {
16884            let agent_id = controlled_session(shared, connection_id, role, &session)?;
16885            validate_node_text("prompt", &text)?;
16886            shared.require_session_runtime_policy(
16887                &session,
16888                ProviderRuntimeRequirement::SemanticPrompt,
16889            )?;
16890            let framing = prompt_framing(&agent_id);
16891            shared
16892                .dispatch_input_bounded(
16893                    &session,
16894                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
16895                )
16896                .await?;
16897            Ok(NodeResponse::Accepted)
16898        }
16899        NodeRequest::Paste { session, text } => {
16900            controlled_session(shared, connection_id, role, &session)?;
16901            validate_node_text("paste", &text)?;
16902            shared.require_session_runtime_policy(
16903                &session,
16904                ProviderRuntimeRequirement::SemanticPrompt,
16905            )?;
16906            shared
16907                .dispatch_input_bounded(
16908                    &session,
16909                    InputAction::InsertDraft(PromptPayload {
16910                        text,
16911                        framing: PromptFraming::BracketedPaste,
16912                    }),
16913                )
16914                .await?;
16915            Ok(NodeResponse::Accepted)
16916        }
16917        NodeRequest::Input { session, text } => {
16918            controlled_session(shared, connection_id, role, &session)?;
16919            validate_node_text("terminal input", &text)?;
16920            shared
16921                .dispatch_input_bounded(
16922                    &session,
16923                    InputAction::TerminalText(TerminalText { text }),
16924                )
16925                .await?;
16926            Ok(NodeResponse::Accepted)
16927        }
16928        NodeRequest::TerminalBytes { session, bytes } => {
16929            controlled_session(shared, connection_id, role, &session)?;
16930            shared.sessionize_spawn(session.session.instance_id);
16931            let action = terminal_bytes_action(bytes)?;
16932            shared
16933                .dispatch_input_bounded(&session, action)
16934                .await?;
16935            Ok(NodeResponse::Accepted)
16936        }
16937        NodeRequest::TerminalControl { session, control } => {
16938            controlled_session(shared, connection_id, role, &session)?;
16939            shared
16940                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16941                .await?;
16942            Ok(NodeResponse::Accepted)
16943        }
16944        NodeRequest::Resize { session, size } => {
16945            controlled_session(shared, connection_id, role, &session)?;
16946            shared.sessionize_spawn(session.session.instance_id);
16947            if !size.is_valid() {
16948                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16949            }
16950            shared.dispatch_resize_bounded(&session, size).await?;
16951            Ok(NodeResponse::Accepted)
16952        }
16953        NodeRequest::Interrupt { session } => {
16954            controlled_session(shared, connection_id, role, &session)?;
16955            shared
16956                .dispatch_input_bounded(
16957                    &session,
16958                    InputAction::TerminalControl(TerminalControl::Interrupt),
16959                )
16960                .await?;
16961            Ok(NodeResponse::Accepted)
16962        }
16963        NodeRequest::Stop { session, force } => {
16964            controlled_session(shared, connection_id, role, &session)?;
16965            shared.sessionize_spawn(session.session.instance_id);
16966            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16967            shared.retire_session_grok_leader(session.session.instance_id);
16968            Ok(NodeResponse::Accepted)
16969        }
16970        NodeRequest::Remove { session } => {
16971            controlled_session(shared, connection_id, role, &session)?;
16972            shared.remove_session(&session).await?;
16973            Ok(NodeResponse::Accepted)
16974        }
16975        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16976            shared.require_controller(connection_id, role)?;
16977            let snapshot = shared.require_acp_session(&session)?;
16978            let interaction = snapshot
16979                .provider
16980                .interactions
16981                .iter()
16982                .find(|interaction| {
16983                    interaction_correlation(
16984                        session.session.instance_id,
16985                        session.session.generation,
16986                        interaction.id.0,
16987                    ) == correlation_id
16988                })
16989                .ok_or_else(|| failure(
16990                    NodeFailureCode::InvalidRequest,
16991                    "unknown interaction correlation",
16992                ))?;
16993            if interaction.status != ProviderInteractionStatus::Pending {
16994                return Err(failure(
16995                    NodeFailureCode::BackendOperationFailed,
16996                    "interaction has already been resolved",
16997                ));
16998            }
16999            response
17000                .validate_for(interaction.interaction_kind)
17001                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17002            let interaction_id = interaction.id;
17003            let response_kind = response.kind();
17004            let response_outcome = response.outcome();
17005            shared.dispatch(ControlCommand::ResolveInteraction {
17006                instance_id: session.session.instance_id,
17007                generation: session.session.generation,
17008                interaction_id,
17009                response,
17010            })?;
17011            // The one line that lets an operator compare what THIS node
17012            // recorded against what the provider itself reports doing --
17013            // measured live on a kimi run where our side resolved `true`
17014            // while the agent reported "rejected" and neither layer had a
17015            // line to line up against the other.
17016            tracing::info!(
17017                node_id = %shared.node_id,
17018                session = ?session.session,
17019                correlation_id = %correlation_id,
17020                interaction_id = interaction_id.0,
17021                response_kind = ?response_kind,
17022                outcome = ?response_outcome,
17023                "interaction resolved",
17024            );
17025            Ok(NodeResponse::Accepted)
17026        }
17027        NodeRequest::SetSessionMode { session, mode_id } => {
17028            shared.require_controller(connection_id, role)?;
17029            shared.require_acp_session(&session)?;
17030            shared.dispatch(ControlCommand::SetSessionMode {
17031                instance_id: session.session.instance_id,
17032                mode_id,
17033            })?;
17034            Ok(NodeResponse::Accepted)
17035        }
17036        NodeRequest::SetSessionConfigOption {
17037            session,
17038            option_id,
17039            value_json,
17040        } => {
17041            shared.require_controller(connection_id, role)?;
17042            shared.require_acp_session(&session)?;
17043            shared.dispatch(ControlCommand::SetSessionConfigOption {
17044                instance_id: session.session.instance_id,
17045                option_id,
17046                value_json,
17047            })?;
17048            Ok(NodeResponse::Accepted)
17049        }
17050        NodeRequest::SetSessionModel { session, model_id } => {
17051            shared.require_controller(connection_id, role)?;
17052            shared.require_acp_session(&session)?;
17053            shared.dispatch(ControlCommand::SetSessionModel {
17054                instance_id: session.session.instance_id,
17055                model_id,
17056            })?;
17057            Ok(NodeResponse::Accepted)
17058        }
17059        NodeRequest::Shutdown => {
17060            shared.require_controller(connection_id, role)?;
17061            shared.begin_shutdown_locked().await?;
17062            Ok(NodeResponse::ShuttingDown)
17063        }
17064    }
17065}
17066
17067fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17068    shared.require_controller(connection_id, role)?;
17069    shared.validate_address(session)
17070}
17071
17072/// Selects the sole history candidate matching `provider_session_id` when
17073/// the managed record carries a verified identity, or the sole candidate in
17074/// the discovered set at all when it does not (see
17075/// `export_context_pack_for_session_record_inner`'s identity match). Either
17076/// way, more than one match refuses instead of guessing which one is exact.
17077fn select_history_candidate(
17078    candidates: &[HistoryCandidateSummary],
17079    provider_session_id: Option<&str>,
17080) -> Result<String, NodeFailure> {
17081    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17082        Some(id) => candidate.session_id_hint == id,
17083        None => true,
17084    });
17085    let candidate = matches.next().ok_or_else(|| {
17086        failure(
17087            NodeFailureCode::SessionRecordConflict,
17088            if provider_session_id.is_some() {
17089                "no history candidate matches the managed provider session"
17090            } else {
17091                "no history candidate exists for the managed session"
17092            },
17093        )
17094    })?;
17095    if matches.next().is_some() {
17096        return Err(failure(
17097            NodeFailureCode::SessionRecordConflict,
17098            if provider_session_id.is_some() {
17099                "history identity is ambiguous for the managed provider session"
17100            } else {
17101                "history is ambiguous without a verified provider session identity"
17102            },
17103        ));
17104    }
17105    Ok(candidate.id.clone())
17106}
17107
17108fn resolved_spawn_profile_summary(
17109    profile: &SpawnProfileDefaults,
17110    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17111) -> Option<SpawnProfileSummary> {
17112    let environment_profile = match profile.environment_profile_id.as_ref() {
17113        None => None,
17114        Some(profile_id) => {
17115            let binding = environment_profiles.get(profile_id)?;
17116            if binding.provider != profile.provider
17117                || binding.native_profile_id(profile.mode).is_none()
17118            {
17119                return None;
17120            }
17121            Some(ResolvedEnvironmentProfileReceipt {
17122                profile_id: binding.id.clone(),
17123                profile_revision: binding.revision.clone(),
17124                network_allowlist: None,
17125                browser_profile_id: None,
17126            })
17127        }
17128    };
17129    Some(SpawnProfileSummary {
17130        id: profile.profile_id.clone(),
17131        revision: profile.revision.clone(),
17132        environment_profile,
17133    })
17134}
17135
17136/// True if `current` is an acceptable version of `expected` at export
17137/// revalidation/commit time.
17138///
17139/// When `allow_clean_detachment` is `false` (the on-demand,
17140/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
17141/// be byte-identical to `expected` and still actively bound to `session` —
17142/// the original, unchanged invariant.
17143///
17144/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
17145/// ALSO be the exact Live -> Dormant transition that the same clean exit this
17146/// export reacts to produces on its own: `state` Live -> Dormant,
17147/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
17148/// advancing. Every other field — including `provider_session`, `context`,
17149/// `bundle`, `task_binding` — must still match exactly. A record that changed
17150/// in any other way (rebind, a different active session, an identity
17151/// conflict, capacity eviction) is rejected either way; this widens nothing
17152/// about which sessions are eligible, only how the record's own expected
17153/// detachment is tolerated mid-export.
17154fn session_record_export_target_matches(
17155    expected: &ManagedSessionRecord,
17156    current: &ManagedSessionRecord,
17157    session: &SessionAddress,
17158    allow_clean_detachment: bool,
17159) -> bool {
17160    if current == expected && current.active_session.as_ref() == Some(session) {
17161        return current.state == ManagedSessionState::Live;
17162    }
17163    allow_clean_detachment
17164        && current.state == ManagedSessionState::Dormant
17165        && current.active_session.is_none()
17166        && current.record_id == expected.record_id
17167        && current.display_name == expected.display_name
17168        && current.provider == expected.provider
17169        && current.mode == expected.mode
17170        && current.workspace_id == expected.workspace_id
17171        && current.canonical_root == expected.canonical_root
17172        && current.provider_session == expected.provider_session
17173        && current.environment_profile == expected.environment_profile
17174        && current.bundle == expected.bundle
17175        && current.context_id == expected.context_id
17176        && current.context == expected.context
17177        && current.exported_context == expected.exported_context
17178        && current.task_binding == expected.task_binding
17179        && current.created_at_unix_ms == expected.created_at_unix_ms
17180        && current.last_error == expected.last_error
17181}
17182
17183fn session_record_context_export_binding_is_exact(
17184    expected: &ManagedSessionRecord,
17185    current: &ManagedSessionRecord,
17186    identity: Option<&ProviderSessionIdentity>,
17187    session: &SessionAddress,
17188    binding: &SessionBinding,
17189    provider: &AgentId,
17190    current_root: &str,
17191    allow_clean_detachment: bool,
17192) -> bool {
17193    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17194        && current.provider_session.as_ref() == identity
17195        && current.workspace_id == session.workspace_id
17196        && provider == &current.provider
17197        && binding.workspace_id == session.workspace_id
17198        && binding.generation == session.session.generation
17199        && binding.record_id.as_ref() == Some(&current.record_id)
17200        && platform::roots_equal(
17201            current_root,
17202            windows_path_text(&current.canonical_root),
17203        )
17204}
17205
17206fn session_record_context_export_source_is_usable(
17207    record: &ManagedSessionRecord,
17208    status: &SessionStatus,
17209    allow_clean_detachment: bool,
17210) -> bool {
17211    (record.state == ManagedSessionState::Live
17212        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17213        && context_pack_source_status_is_usable(status)
17214}
17215
17216fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17217    PromptFraming::BracketedPaste
17218}
17219
17220fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17221    if text.len() > MAX_NODE_TEXT_BYTES {
17222        return Err(failure(
17223            NodeFailureCode::InvalidRequest,
17224            &format!(
17225                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17226                text.len(),
17227            ),
17228        ));
17229    }
17230    Ok(())
17231}
17232
17233fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17234    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17235        return Err(failure(
17236            NodeFailureCode::InvalidRequest,
17237            &format!(
17238                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17239                bytes.len(),
17240            ),
17241        ));
17242    }
17243    Ok(())
17244}
17245
17246fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17247    validate_terminal_bytes(&bytes)?;
17248    Ok(InputAction::TerminalBytes(bytes))
17249}
17250
17251fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17252    if root.is_empty()
17253        || root.len() > MAX_WORKSPACE_ROOT_BYTES
17254        || root.chars().any(char::is_control)
17255    {
17256        return Err(failure(
17257            NodeFailureCode::InvalidWorkspaceRoot,
17258            &format!(
17259                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17260            ),
17261        ));
17262    }
17263    Ok(())
17264}
17265
17266fn canonical_standalone_workspace_candidate(
17267    workspace_id: &WorkspaceId,
17268    root: &str,
17269) -> Result<String, NodeServerError> {
17270    let path = Path::new(root);
17271    match std::fs::symlink_metadata(path) {
17272        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17273            .map(|workspace| workspace.canonical_root),
17274        Err(error) if error.kind() == io::ErrorKind::NotFound => {
17275            if !path.is_absolute() {
17276                return Err(NodeServerError::InvalidWorkspaceRoot {
17277                    workspace_id: workspace_id.clone(),
17278                    path: root.to_owned(),
17279                    message: "path is not absolute".to_owned(),
17280                });
17281            }
17282            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17283                workspace_id: workspace_id.clone(),
17284                path: root.to_owned(),
17285                message: "path has no standalone directory name".to_owned(),
17286            })?;
17287            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17288                workspace_id: workspace_id.clone(),
17289                path: root.to_owned(),
17290                message: "path has no parent directory".to_owned(),
17291            })?;
17292            let canonical = std::fs::canonicalize(parent)
17293                .map(|parent| parent.join(name))
17294                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17295                    workspace_id: workspace_id.clone(),
17296                    path: root.to_owned(),
17297                    message: error.to_string(),
17298                })?;
17299            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17300                NodeServerError::InvalidWorkspaceRoot {
17301                    workspace_id: workspace_id.clone(),
17302                    path: path.to_string_lossy().into_owned(),
17303                    message: "canonical path is not valid Unicode".to_owned(),
17304                }
17305            })?;
17306            let canonical_root = platform::normalize_canonical_root(canonical_root);
17307            validate_workspace_root(workspace_id, &canonical_root)?;
17308            Ok(canonical_root)
17309        }
17310        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17311            workspace_id: workspace_id.clone(),
17312            path: root.to_owned(),
17313            message: error.to_string(),
17314        }),
17315    }
17316}
17317
17318fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17319    let Some(revision) = revision else {
17320        return Ok(());
17321    };
17322    if revision.is_empty()
17323        || revision.len() > MAX_REPOSITORY_PATH_BYTES
17324        || revision.chars().any(char::is_control)
17325    {
17326        return Err(failure(
17327            NodeFailureCode::InvalidRequest,
17328            &format!(
17329                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17330            ),
17331        ));
17332    }
17333    Ok(())
17334}
17335
17336fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17337    let code = match error.kind {
17338        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17339        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17340        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17341        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17342        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17343        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17344        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17345    };
17346    failure(code, &error.message)
17347}
17348
17349fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17350    let code = match error.kind {
17351        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17352        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17353        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17354            NodeFailureCode::BackendOperationFailed
17355        }
17356        StandaloneWorkspaceErrorKind::RecoveryRequired => {
17357            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17358        }
17359    };
17360    failure(code, error.message)
17361}
17362
17363fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17364    let (code, message) = match error.kind {
17365        GitWorktreeErrorKind::Invalid => (
17366            NodeFailureCode::InvalidRequest,
17367            "managed Git input is invalid",
17368        ),
17369        GitWorktreeErrorKind::NotRepository => (
17370            NodeFailureCode::NotGitRepository,
17371            "managed source is not a Git repository",
17372        ),
17373        GitWorktreeErrorKind::Conflict => (
17374            NodeFailureCode::WorktreeConflict,
17375            "managed Git identity conflicts with existing repository state",
17376        ),
17377        GitWorktreeErrorKind::Protected => (
17378            NodeFailureCode::ManagedWorktreeOwnershipConflict,
17379            "managed worktree ownership validation failed",
17380        ),
17381        GitWorktreeErrorKind::Dirty => (
17382            NodeFailureCode::WorktreeDirty,
17383            "managed worktree contains uncommitted changes",
17384        ),
17385        GitWorktreeErrorKind::Locked => (
17386            NodeFailureCode::WorktreeLocked,
17387            "managed worktree is locked",
17388        ),
17389        GitWorktreeErrorKind::Failed => (
17390            NodeFailureCode::BackendOperationFailed,
17391            "managed Git operation failed",
17392        ),
17393    };
17394    NodeFailure { code, message: message.to_owned() }
17395}
17396
17397fn managed_spawn_receipt(
17398    resolved: &ResolvedSpawnSpec,
17399    incarnation_id: NodeIncarnationId,
17400    session: SessionAddress,
17401    lease: ManagedWorktreeLeaseSnapshot,
17402    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17403    bundle: Option<ResolvedBundleReceipt>,
17404    context: Option<ResolvedContextPackReceipt>,
17405) -> ManagedWorktreeSpawnReceipt {
17406    let mut managed = resolved.clone();
17407    managed.target.worktree_id = Some(lease.workspace_id.clone());
17408    ManagedWorktreeSpawnReceipt {
17409        spawn: managed.receipt_with_materialization(
17410            incarnation_id,
17411            session,
17412            environment_profile,
17413            bundle,
17414            context,
17415        ),
17416        lease,
17417    }
17418}
17419
17420fn validate_selected_worktree(
17421    source_root: &str,
17422    selected_root: &str,
17423    worktrees: &[NativeGitWorktreeSnapshot],
17424) -> Result<(), NodeFailure> {
17425    if !worktrees
17426        .iter()
17427        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17428    {
17429        return Err(failure(
17430            NodeFailureCode::WorktreeProtected,
17431            "source workspace is not an authoritative non-bare Git worktree root",
17432        ));
17433    }
17434    if !worktrees.iter().any(|worktree| {
17435        worktree_paths_equal(&worktree.path, selected_root)
17436            && !worktree.is_main
17437            && !worktree.is_bare
17438    }) {
17439        return Err(failure(
17440            NodeFailureCode::WorktreeProtected,
17441            "selected workspace is not an eligible Git worktree in the source repository",
17442        ));
17443    }
17444    Ok(())
17445}
17446
17447fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17448    let code = match error.kind() {
17449        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17450        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17451            NodeFailureCode::RepositoryPathUnsafe
17452        }
17453        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17454        WorkspaceFileReadErrorKind::NotRegularFile => {
17455            NodeFailureCode::RepositoryFileNotRegular
17456        }
17457        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17458            NodeFailureCode::RepositoryFileReadFailed
17459        }
17460        WorkspaceFileReadErrorKind::RevisionConflict => {
17461            NodeFailureCode::RepositoryFileRevisionConflict
17462        }
17463        WorkspaceFileReadErrorKind::AlreadyExists
17464        | WorkspaceFileReadErrorKind::ParentNotFound
17465        | WorkspaceFileReadErrorKind::ParentNotDirectory
17466        | WorkspaceFileReadErrorKind::Canceled => {
17467            NodeFailureCode::RepositoryFileReadFailed
17468        }
17469    };
17470    failure(code, "repository file read failed")
17471}
17472
17473fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17474    let code = match error.kind() {
17475        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17476        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17477            NodeFailureCode::RepositoryPathUnsafe
17478        }
17479        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17480        WorkspaceFileReadErrorKind::NotRegularFile => {
17481            NodeFailureCode::RepositoryFileNotRegular
17482        }
17483        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17484            NodeFailureCode::RepositoryFileWriteFailed
17485        }
17486        WorkspaceFileReadErrorKind::RevisionConflict => {
17487            NodeFailureCode::RepositoryFileRevisionConflict
17488        }
17489        WorkspaceFileReadErrorKind::AlreadyExists
17490        | WorkspaceFileReadErrorKind::ParentNotFound
17491        | WorkspaceFileReadErrorKind::ParentNotDirectory
17492        | WorkspaceFileReadErrorKind::Canceled => {
17493            NodeFailureCode::RepositoryFileWriteFailed
17494        }
17495    };
17496    failure(code, "repository file write failed")
17497}
17498
17499async fn settle_workspace_entry_create<T>(
17500    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17501    commit_state: Arc<AtomicU8>,
17502    deadline: Duration,
17503    timeout_message: &'static str,
17504    task_failure_message: &'static str,
17505) -> Result<T, NodeFailure> {
17506    let joined = match timeout(deadline, &mut task).await {
17507        Ok(joined) => joined,
17508        Err(_) => {
17509            if commit_state
17510                .compare_exchange(
17511                    WORKSPACE_ENTRY_CREATE_PENDING,
17512                    WORKSPACE_ENTRY_CREATE_CANCELED,
17513                    Ordering::AcqRel,
17514                    Ordering::Acquire,
17515                )
17516                .is_ok()
17517            {
17518                return Err(failure(
17519                    NodeFailureCode::RepositoryEntryCreateTimedOut,
17520                    timeout_message,
17521                ));
17522            }
17523            debug_assert_eq!(
17524                commit_state.load(Ordering::Acquire),
17525                WORKSPACE_ENTRY_CREATE_COMMITTING,
17526            );
17527            task.await
17528        }
17529    };
17530    joined
17531        .map_err(|_| {
17532            failure(
17533                NodeFailureCode::RepositoryEntryCreateFailed,
17534                task_failure_message,
17535            )
17536        })?
17537        .map_err(workspace_entry_create_failure)
17538}
17539
17540fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17541    let code = match error.kind() {
17542        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17543        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17544            NodeFailureCode::RepositoryPathUnsafe
17545        }
17546        WorkspaceFileReadErrorKind::AlreadyExists => {
17547            NodeFailureCode::RepositoryEntryAlreadyExists
17548        }
17549        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17550            NodeFailureCode::RepositoryParentNotFound
17551        }
17552        WorkspaceFileReadErrorKind::ParentNotDirectory
17553        | WorkspaceFileReadErrorKind::NotRegularFile => {
17554            NodeFailureCode::RepositoryParentNotDirectory
17555        }
17556        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17557            NodeFailureCode::RepositoryEntryCreateFailed
17558        }
17559        WorkspaceFileReadErrorKind::RevisionConflict => {
17560            NodeFailureCode::RepositoryEntryCreateFailed
17561        }
17562        WorkspaceFileReadErrorKind::Canceled => {
17563            NodeFailureCode::RepositoryEntryCreateTimedOut
17564        }
17565    };
17566    failure(code, "repository entry create failed")
17567}
17568
17569fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17570    let value = digest(&SHA256, bytes)
17571        .as_ref()
17572        .iter()
17573        .map(|byte| format!("{byte:02x}"))
17574        .collect();
17575    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17576}
17577
17578fn managed_spawn_request_digest_v2(
17579    request: &ManagedWorktreeSpawnRequestV2,
17580) -> Result<String, NodeFailure> {
17581    let encoded = serde_json::to_vec(request).map_err(|_| {
17582        failure(
17583            NodeFailureCode::InvalidRequest,
17584            "managed worktree V2 request could not be canonicalized",
17585        )
17586    })?;
17587    let mut value = String::with_capacity(71);
17588    value.push_str("sha256:");
17589    for byte in digest(&SHA256, &encoded).as_ref() {
17590        use std::fmt::Write as _;
17591        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17592    }
17593    Ok(value)
17594}
17595
17596fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17597    let code = match error {
17598        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17599        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17600        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17601        NativeSessionPreviewError::UnsupportedProvider
17602        | NativeSessionPreviewError::PreviewUnavailable
17603        | NativeSessionPreviewError::SessionNotFound
17604        | NativeSessionPreviewError::AmbiguousSession => {
17605            NodeFailureCode::BackendOperationFailed
17606        }
17607    };
17608    failure(code, "native session preview failed")
17609}
17610
17611fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17612    let code = match error.kind() {
17613        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17614        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17615    };
17616    failure(code, "host directory browse failed")
17617}
17618
17619fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17620    let code = match error {
17621        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17622        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17623        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17624            NodeFailureCode::DeliveryStageConflict
17625        }
17626        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17627        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17628            NodeFailureCode::DeliveryChunkOutOfOrder
17629        }
17630        DeliveryStoreError::BlobDigestMismatch => {
17631            NodeFailureCode::DeliveryBlobDigestMismatch
17632        }
17633        DeliveryStoreError::BundleDigestMismatch => {
17634            NodeFailureCode::DeliveryBundleDigestMismatch
17635        }
17636        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17637        DeliveryStoreError::Unavailable
17638        | DeliveryStoreError::Corrupt
17639        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17640    };
17641    failure(code, "delivery operation failed")
17642}
17643
17644fn validate_workspace_root(
17645    workspace_id: &WorkspaceId,
17646    canonical_root: &str,
17647) -> Result<(), NodeServerError> {
17648    #[cfg(windows)]
17649    if canonical_root.starts_with(r"\\") {
17650        return Err(NodeServerError::InvalidWorkspaceRoot {
17651            workspace_id: workspace_id.clone(),
17652            path: canonical_root.to_owned(),
17653            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17654        });
17655    }
17656    if !platform::workspace_root_supported(canonical_root)
17657        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17658    {
17659        return Err(NodeServerError::InvalidWorkspaceRoot {
17660            workspace_id: workspace_id.clone(),
17661            path: canonical_root.to_owned(),
17662            message: "canonical path must be absolute, bounded, and free of control characters"
17663                .to_owned(),
17664        });
17665    }
17666    Ok(())
17667}
17668
17669fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17670    NodeFailure {
17671        code,
17672        message: node_failure_category(code).to_owned(),
17673    }
17674}
17675
17676fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17677    let code = match error {
17678        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17679        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17680        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17681        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17682        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17683        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17684        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17685        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17686        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17687    };
17688    failure(code, "harness MCP proxy operation failed")
17689}
17690
17691/// Numbers each harness-MCP overlay this process constructs, in
17692/// construction order, so two overlays built back to back in one node process
17693/// still get distinct trace files.
17694static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17695
17696/// Builds the optional trace environment entry of one harness-MCP overlay.
17697/// The caller's launch description names both the entry and the node-process
17698/// variable that opts in: when that variable holds a non-empty directory, the
17699/// entry names a trace file under it. Returns `None` when the launch has no
17700/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
17701/// the directory; that is the operator's job.
17702fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17703    let trace = launch.trace.as_ref()?;
17704    let trace_dir = std::env::var(&trace.dir_env).ok()?;
17705    if trace_dir.is_empty() {
17706        return None;
17707    }
17708    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17709    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17710    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17711}
17712
17713fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17714    let remaining = deadline.saturating_duration_since(Instant::now());
17715    if remaining.is_zero() {
17716        Err(failure(
17717            NodeFailureCode::SpawnDeadlineExceeded,
17718            "spawn deadline elapsed",
17719        ))
17720    } else {
17721        Ok(remaining)
17722    }
17723}
17724
17725fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17726    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17727    match deadline {
17728        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17729        None => Ok(bounded),
17730    }
17731}
17732
17733fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17734    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17735        failure(
17736            NodeFailureCode::SpawnDeadlineExceeded,
17737            "spawn dispatch exceeded the spawn deadline",
17738        )
17739    } else {
17740        error
17741    }
17742}
17743
17744fn spawn_runtime_capabilities(
17745    capabilities: &SpawnRequiredCapabilities,
17746) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17747    capabilities
17748        .iter()
17749        .map(|capability| match capability.as_str() {
17750            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17751            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17752            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17753            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17754                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17755            }
17756            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17757            _ => Err(failure(
17758                NodeFailureCode::UnsupportedSpawnCapability,
17759                "spawn required capability is unknown",
17760            )),
17761        })
17762        .collect()
17763}
17764
17765fn persistence_failure(_error: io::Error) -> NodeFailure {
17766    NodeFailure {
17767        code: NodeFailureCode::BackendOperationFailed,
17768        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17769    }
17770}
17771
17772fn node_failure_category(code: NodeFailureCode) -> &'static str {
17773    match code {
17774        NodeFailureCode::InvalidRequest => "invalid-request",
17775        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17776        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17777        NodeFailureCode::ReservationNotFound => "reservation-not-found",
17778        NodeFailureCode::ReservationConflict => "reservation-conflict",
17779        NodeFailureCode::ReservationExpired => "reservation-expired",
17780        NodeFailureCode::BindingMismatch => "binding-mismatch",
17781        NodeFailureCode::NotActivated => "not-activated",
17782        NodeFailureCode::CallNotFound => "call-not-found",
17783        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17784        NodeFailureCode::ResponseTooLarge => "response-too-large",
17785        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17786        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17787        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17788        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17789        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17790        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17791        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17792        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17793        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17794        NodeFailureCode::Unauthorized => "unauthorized",
17795        NodeFailureCode::ObserverReadOnly => "observer-read-only",
17796        NodeFailureCode::ControllerBusy => "controller-busy",
17797        NodeFailureCode::ControllerRequired => "controller-required",
17798        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17799        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17800        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17801        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17802        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17803        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17804        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17805        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17806        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17807        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17808        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17809        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17810        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17811        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17812        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17813        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17814        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17815        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17816        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17817        NodeFailureCode::GitReadFailed => "git-read-failed",
17818        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17819        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17820        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17821        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17822        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17823        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17824        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17825        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17826        NodeFailureCode::TurnInFlight => "turn-in-flight",
17827        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17828        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17829        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17830            "unsupported-network-allowlist-mapping"
17831        }
17832        NodeFailureCode::BrowserStationProbeUnavailable => {
17833            "browser-station-probe-unavailable"
17834        }
17835        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17836        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17837        NodeFailureCode::EnvironmentProfileBindingMismatch => {
17838            "environment-profile-binding-mismatch"
17839        }
17840        NodeFailureCode::UnknownBundle => "unknown-bundle",
17841        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17842        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17843        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17844        NodeFailureCode::ContextPackBusy => "context-pack-busy",
17845        NodeFailureCode::ContextPackMaterializationFailed => {
17846            "context-pack-materialization-failed"
17847        }
17848        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17849        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17850        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17851        NodeFailureCode::WorkspaceBusy => "workspace-busy",
17852        NodeFailureCode::LastWorkspace => "last-workspace",
17853        NodeFailureCode::NotGitRepository => "not-git-repository",
17854        NodeFailureCode::WorktreeConflict => "worktree-conflict",
17855        NodeFailureCode::WorktreeProtected => "worktree-protected",
17856        NodeFailureCode::WorktreeDirty => "worktree-dirty",
17857        NodeFailureCode::WorktreeLocked => "worktree-locked",
17858        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17859        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17860        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17861        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17862            "managed-worktree-profile-revision-mismatch"
17863        }
17864        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17865        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17866            "standalone-workspace-recovery-required"
17867        }
17868        NodeFailureCode::UnknownSession => "unknown-session",
17869        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17870        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17871        NodeFailureCode::SessionRecordBusy => "session-record-busy",
17872        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17873        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17874        NodeFailureCode::WorkspaceRegistrationRequired => {
17875            "workspace-registration-required"
17876        }
17877        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17878        NodeFailureCode::StaleGeneration => "stale-generation",
17879        NodeFailureCode::BackendBusy => "backend-busy",
17880        NodeFailureCode::BackendDisconnected => "backend-disconnected",
17881        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17882        NodeFailureCode::ShuttingDown => "shutting-down",
17883    }
17884}
17885
17886fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17887    NodeServerError::DurableState(io::Error::new(error.kind(), category))
17888}
17889
17890fn durable_state_load_error(error: io::Error) -> NodeServerError {
17891    let category = match session_registry::state_load_refusal(&error) {
17892        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17893            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17894        }
17895        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17896            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17897        }
17898        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17899            DURABLE_STATE_CONFLICT_ERROR
17900        }
17901        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17902    };
17903    durable_state_server_error(error, category)
17904}
17905
17906fn unix_time_ms() -> u64 {
17907    std::time::SystemTime::now()
17908        .duration_since(std::time::UNIX_EPOCH)
17909        .unwrap_or_default()
17910        .as_millis()
17911        .min(u64::MAX as u128) as u64
17912}
17913
17914fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17915    if record.provider_session.is_some() {
17916        ManagedSessionState::Dormant
17917    } else {
17918        ManagedSessionState::Unavailable
17919    }
17920}
17921
17922fn active_record_state(
17923    identity_admitted: bool,
17924    provider_session_is_known: bool,
17925) -> ManagedSessionState {
17926    if identity_admitted && !provider_session_is_known {
17927        ManagedSessionState::IdentityPending
17928    } else {
17929        ManagedSessionState::Live
17930    }
17931}
17932
17933fn managed_worktree_record_holders(
17934    records: &[ManagedSessionRecord],
17935) -> Vec<(SessionRecordId, WorkspaceId)> {
17936    records
17937        .iter()
17938        .filter(|record| record.provider_session.is_some())
17939        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17940        .collect()
17941}
17942
17943fn managed_spawn_record_ownership(
17944    binding: &SessionBinding,
17945    effective_runtime_policy: ProviderRuntimePolicy,
17946) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17947    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17948    if effective_runtime_policy.provider_session_identity {
17949        (binding.record_id.clone(), None)
17950    } else {
17951        (None, binding.record_id.clone())
17952    }
17953}
17954
17955#[derive(Debug, Error)]
17956pub enum NodeServerError {
17957    #[error("node endpoint must be a bounded absolute local transport path")]
17958    InvalidEndpoint,
17959    #[error("node access token must contain 1..=4096 bytes")]
17960    InvalidAccessToken,
17961    #[error("node HTTP observer must listen on a loopback address: {0}")]
17962    InvalidApiListen(std::net::SocketAddr),
17963    #[error("node browser bridge must listen on a loopback address: {0}")]
17964    InvalidBridgeListen(std::net::SocketAddr),
17965    #[error("node browser bridge token must contain 1..=4096 bytes")]
17966    InvalidBridgeToken,
17967    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17968    BridgeUnderlayRequiresBridgeListen,
17969    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17970    BridgeUnderlayRequiresBridgeToken,
17971    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17972    InvalidBridgeUnderlayToken,
17973    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17974    InvalidBridgeUnderlayKey,
17975    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17976    InvalidCallHome(std::net::SocketAddr),
17977    #[error("node could not reach its call-home relay: {0}")]
17978    CallHomeTransport(#[source] std::io::Error),
17979    #[error("node durable state path must be an absolute file path: {0}")]
17980    InvalidStatePath(String),
17981    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17982    InvalidHarnessMcpHelper,
17983    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17984    InvalidNetworkAllowlistCatalogPath(String),
17985    #[error("network allowlist catalog could not be read: {0}")]
17986    NetworkAllowlistCatalogIo(#[source] std::io::Error),
17987    #[error("network allowlist catalog line {line}: {message}")]
17988    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17989    #[error("network allowlist catalog: {0}")]
17990    InvalidNetworkAllowlistCatalog(String),
17991    #[error("network allowlist catalog exceeds the {max}-entry limit")]
17992    NetworkAllowlistCatalogCapacity { max: usize },
17993    #[error("the local state directory is unavailable; supply an explicit state path")]
17994    LocalStateDirectoryUnavailable,
17995    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17996    LocalRuntimeDirectoryUnavailable,
17997    #[error("node durable state failed: {0}")]
17998    DurableState(io::Error),
17999    #[error("node requires at least one configured workspace")]
18000    NoWorkspaces,
18001    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18002    InvalidWorkspaceRoot {
18003        workspace_id: WorkspaceId,
18004        path: String,
18005        message: String,
18006    },
18007    #[error("duplicate workspace ID: {0}")]
18008    DuplicateWorkspaceId(WorkspaceId),
18009    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18010    DuplicateWorkspaceRoot {
18011        first: WorkspaceId,
18012        second: WorkspaceId,
18013        root: String,
18014    },
18015    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18016    ManagedWorktreeProfileRequired(WorkspaceId),
18017    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18018    ManagedWorktreeProfileModeMismatch(WorkspaceId),
18019    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18020    DuplicateManagedWorktreeProfile {
18021        workspace_id: WorkspaceId,
18022        profile_id: WorktreeProfileId,
18023    },
18024    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18025    ManagedWorktreeProfileCapacity {
18026        workspace_id: WorkspaceId,
18027        max: usize,
18028    },
18029    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18030    InvalidManagedWorktreeProfile {
18031        workspace_id: WorkspaceId,
18032        profile_id: WorktreeProfileId,
18033        message: String,
18034    },
18035    #[error("duplicate node environment profile: {0}")]
18036    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18037    #[error("node environment profile provider is unavailable: {0}")]
18038    EnvironmentProfileProviderUnavailable(AgentId),
18039    #[error("node environment profile capacity is {max}")]
18040    EnvironmentProfileCapacity { max: usize },
18041    #[error("node environment profiles reuse native profile ID '{0}'")]
18042    DuplicateNativeEnvironmentProfile(String),
18043    #[error("node native environment profile is invalid: {0}")]
18044    NativeEnvironmentProfile(String),
18045    #[error("node session-environment materialization root must be absolute")]
18046    InvalidSessionEnvironmentRoot,
18047    #[error("node session-environment materializer is unavailable")]
18048    SessionEnvironmentMaterializerRequired,
18049    #[error("node session-environment materializer failed to initialize")]
18050    SessionEnvironmentMaterializer,
18051    #[error("node bundle catalog is invalid: {0}")]
18052    BundleCatalog(String),
18053    #[error("node delivery store failed to initialize: {0}")]
18054    DeliveryStore(String),
18055    #[error("node context pack store failed to initialize")]
18056    ContextPackStore,
18057    #[error("active agent registry failed: {0}")]
18058    Registry(String),
18059    #[error("node provider contract manifest is invalid: {0}")]
18060    ProviderContractManifest(String),
18061    #[error("named pipe I/O failed: {0}")]
18062    Io(#[from] io::Error),
18063    #[error("node HTTP observer failed: {0}")]
18064    HttpApi(io::Error),
18065    #[error("node browser bridge failed: {0}")]
18066    BridgeApi(io::Error),
18067    #[error(transparent)]
18068    Frame(#[from] FrameError),
18069    #[error("node handshake failed: {0}")]
18070    Handshake(String),
18071    #[error("node authentication frame was not received before the bounded deadline")]
18072    AuthenticationTimedOut,
18073    #[error("node authentication primitive failed: {0}")]
18074    Authentication(String),
18075    #[error("node incarnation identity generation failed: {0}")]
18076    IncarnationIdentity(String),
18077    #[error("node pre-authentication limiter is closed")]
18078    PreauthClosed,
18079    #[error("node authenticated connection limit was reached")]
18080    AuthenticatedConnectionLimit,
18081    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18082    SignalStreamClosed,
18083    #[error("node stop-all dispatch failed: {0:?}")]
18084    ShutdownDispatch(NodeFailure),
18085    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18086    ShutdownTimedOut { active_native_sessions: usize },
18087}
18088
18089impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18090    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18091        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18092        match error {
18093            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18094            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18095            E::InvalidEntry { line, message } => {
18096                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18097            }
18098            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18099            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18100        }
18101    }
18102}
18103
18104#[cfg(test)]
18105mod agent_stream_blocked_tests {
18106    use super::*;
18107    use gate4agent_types::{
18108        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18109        ProviderSource,
18110    };
18111
18112    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18113        ProviderSource {
18114            family,
18115            binding: AdapterBinding::new(
18116                AdapterId::new(adapter).unwrap(),
18117                "fixture/v1",
18118                AdapterVerification::SyntheticFixture,
18119            )
18120            .unwrap(),
18121        }
18122    }
18123
18124    fn provider_control_event_at(
18125        family: AdapterFamily,
18126        adapter: &str,
18127        source_sequence: u64,
18128        provider_event: ProviderEvent,
18129    ) -> ControlEvent {
18130        ControlEvent {
18131            sequence: 41,
18132            command_id: None,
18133            instance_id: AgentInstanceId(7),
18134            generation: SessionGeneration(3),
18135            event: ControlEventKind::ProviderEvent {
18136                sequence: 8,
18137                source: provider_source(family, adapter),
18138                source_sequence,
18139                event: provider_event,
18140            },
18141        }
18142    }
18143
18144    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18145        provider_control_event_at(family, "fixture", 9, provider_event)
18146    }
18147
18148    fn denied_host_request(
18149        by: ProviderHostDecisionAuthority,
18150        reason: Option<String>,
18151    ) -> ControlEvent {
18152        provider_control_event_at(
18153            AdapterFamily::Acp,
18154            "claude-code",
18155            2,
18156            ProviderEvent::HostRequestObserved {
18157                method: "terminal/create".to_owned(),
18158                params_json: String::new(),
18159                decision: ProviderHostRequestDecision::Denied { by },
18160                outcome: ProviderHostRequestOutcome::Executed,
18161                reason,
18162            },
18163        )
18164    }
18165
18166    fn blocked(
18167        event: &ControlEvent,
18168    ) -> (
18169        Option<String>,
18170        String,
18171        BlockAuthorityV1,
18172        Option<String>,
18173        String,
18174        Option<String>,
18175    ) {
18176        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18177        chunk.validate().expect("minted chunk must validate");
18178        let AgentStreamChunkKindV1::Blocked {
18179            correlation_id,
18180            tool_class,
18181            authority,
18182            reason_kind,
18183            reason,
18184            help,
18185        } = chunk.kind
18186        else {
18187            panic!("expected AgentStreamChunkKindV1::Blocked");
18188        };
18189        (correlation_id, tool_class, authority, reason_kind, reason, help)
18190    }
18191
18192    /// Pins the fleet and every adapter each member declares: the fleet is
18193    /// exactly what `active_registry` says, and no member declares a hook
18194    /// adapter (lifecycle hooks are retired).
18195    #[test]
18196    fn the_enabled_provider_matrix_is_exact() {
18197        let registry = active_registry().unwrap();
18198        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
18199        assert_eq!(
18200            providers
18201                .iter()
18202                .map(|contract| contract.provider.as_str())
18203                .collect::<Vec<_>>(),
18204            // `shell` is the plain PTY provider (operator terminals and smoke tests).
18205            vec!["shell", "claude", "codex", "grok", "kimi"],
18206        );
18207        assert!(adapters.iter().all(|contract| {
18208            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18209        }));
18210    }
18211
18212    /// A gate-denied host request, WITH its reason text carried all the way
18213    /// from the ACP host, mints a `Blocked` chunk naming the gate.
18214    #[test]
18215    fn gate_denied_host_request_mints_a_blocked_chunk() {
18216        let gate_text =
18217            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18218        let event = denied_host_request(
18219            ProviderHostDecisionAuthority::Gate,
18220            Some(gate_text.to_owned()),
18221        );
18222        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18223        assert_eq!(chunk.source_sequence, 2);
18224        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18225        assert_eq!(authority, BlockAuthorityV1::HostGate);
18226        assert_eq!(reason, gate_text);
18227        assert_eq!(correlation_id, None);
18228        assert_eq!(reason_kind, None);
18229        assert_eq!(help, None);
18230        assert_eq!(tool_class, "Write");
18231    }
18232
18233    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.";
18234
18235    /// The owner's classifier sample, verbatim, arriving as a
18236    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
18237    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
18238    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
18239    /// and the guidance tail as `help`.
18240    #[test]
18241    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18242        let event = provider_control_event(
18243            AdapterFamily::Acp,
18244            ProviderEvent::ToolCompleted {
18245                id: "toolu_9".to_owned(),
18246                output: CLASSIFIER_SAMPLE.to_owned(),
18247                is_error: true,
18248                duration_ms: None,
18249                agent_id: None,
18250                non_execution_kind: Some("permission-rule".to_owned()),
18251            },
18252        );
18253        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18254        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18255        assert_eq!(reason, "Blocked by classifier");
18256        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18257        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18258        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18259    }
18260
18261    /// A `session/prompt` RPC error with `data.codexErrorInfo:
18262    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
18263    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
18264    /// vendor code as `reason_kind`.
18265    #[test]
18266    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18267        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.";
18268        let event = provider_control_event(
18269            AdapterFamily::Acp,
18270            ProviderEvent::SessionEnded {
18271                result: "JSON-RPC error -32603: Internal error".to_owned(),
18272                cost_usd: None,
18273                is_error: true,
18274                stop_reason: Some(ProviderStopReason::ProviderError {
18275                    code: -32603,
18276                    message: message.to_owned(),
18277                    vendor_code: Some("usageLimitExceeded".to_owned()),
18278                }),
18279            },
18280        );
18281        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18282        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18283        assert_eq!(reason, message);
18284        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18285        assert_eq!(tool_class, "Session");
18286    }
18287
18288    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
18289    /// `authority: ProviderRefusal`.
18290    #[test]
18291    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18292        let event = provider_control_event(
18293            AdapterFamily::Acp,
18294            ProviderEvent::SessionEnded {
18295                result: "refusal".to_owned(),
18296                cost_usd: None,
18297                is_error: true,
18298                stop_reason: Some(ProviderStopReason::Refusal),
18299            },
18300        );
18301        let (_, _, authority, _, _, _) = blocked(&event);
18302        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18303    }
18304
18305    /// A failed tool completion with no typed `non_execution_kind` -- or one
18306    /// that only says the turn stopped -- is a plain execution failure, never a
18307    /// block; nor is an agent's own narrative claiming one.
18308    #[test]
18309    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18310        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18311            let event = provider_control_event(
18312                AdapterFamily::Acp,
18313                ProviderEvent::ToolCompleted {
18314                    id: "toolu_1".to_owned(),
18315                    output: "command exited with status 1".to_owned(),
18316                    is_error: true,
18317                    duration_ms: Some(12),
18318                    agent_id: None,
18319                    non_execution_kind,
18320                },
18321            );
18322            assert!(agent_stream_chunk(&event).is_none());
18323        }
18324        let narrative = provider_control_event(
18325            AdapterFamily::Acp,
18326            ProviderEvent::ToolCompleted {
18327                id: "toolu_2".to_owned(),
18328                output: "the harness blocked the write, approval needed".to_owned(),
18329                is_error: false,
18330                duration_ms: Some(3),
18331                agent_id: None,
18332                non_execution_kind: None,
18333            },
18334        );
18335        assert!(agent_stream_chunk(&narrative).is_none());
18336    }
18337
18338    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
18339    /// execution outcome: a granted request that failed while running was
18340    /// still authorized.
18341    #[test]
18342    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18343        for (decision, outcome) in [
18344            (
18345                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18346                ProviderHostRequestOutcome::Executed,
18347            ),
18348            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18349            (
18350                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18351                ProviderHostRequestOutcome::Failed {
18352                    error: "terminal/create spawn failed: os error 3".to_owned(),
18353                },
18354            ),
18355        ] {
18356            let event = provider_control_event_at(
18357                AdapterFamily::Acp,
18358                "claude-code",
18359                3,
18360                ProviderEvent::HostRequestObserved {
18361                    method: "fs/read_text_file".to_owned(),
18362                    params_json: String::new(),
18363                    decision: decision.clone(),
18364                    outcome,
18365                    reason: None,
18366                },
18367            );
18368            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18369        }
18370    }
18371
18372    /// A deferred `session/request_permission` resolved by an operator or an
18373    /// expired deadline carries no reason text -- the chunk falls back to a
18374    /// categorical sentence naming the authority rather than inventing detail.
18375    #[test]
18376    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18377        for (by, expected_authority, expected_reason) in [
18378            (
18379                ProviderHostDecisionAuthority::Operator,
18380                BlockAuthorityV1::Operator,
18381                "declined by the operator",
18382            ),
18383            (
18384                ProviderHostDecisionAuthority::DeadlinePolicy,
18385                BlockAuthorityV1::HostDeadline,
18386                "denied by host policy after the operator deadline expired",
18387            ),
18388        ] {
18389            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18390            assert_eq!(authority, expected_authority);
18391            assert_eq!(reason, expected_reason);
18392        }
18393    }
18394
18395    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
18396    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
18397    /// outright rather than truncating.
18398    #[test]
18399    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18400        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18401        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18402            ProviderHostDecisionAuthority::Policy,
18403            Some(oversized),
18404        ));
18405        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18406    }
18407}
18408
18409#[cfg(test)]
18410mod standalone_capability_platform_tests {
18411    use super::*;
18412
18413    #[test]
18414    fn standalone_workspace_capability_requires_windows_identity_guards() {
18415        let capabilities = baseline_capabilities().unwrap();
18416        let advertised = capabilities.iter().any(|capability| {
18417            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18418        });
18419        assert_eq!(advertised, cfg!(windows));
18420
18421        let request = NodeRequest::CreateStandaloneWorkspace {
18422            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18423            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18424            initial_branch: None,
18425        };
18426        assert_eq!(
18427            request_uses_unnegotiated_capability(&request, &capabilities),
18428            !cfg!(windows),
18429        );
18430    }
18431}
18432
18433
18434#[cfg(test)]
18435mod station_knobs_resolve_tests {
18436    use super::*;
18437    use gate4agent_catalog::EnvMutation;
18438    use gate4agent_runtime_native::{
18439        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18440        NativeLaunchProfileId,
18441    };
18442    use std::ffi::OsString;
18443    use std::sync::Arc;
18444
18445    struct EmptyEnvironmentResolver;
18446
18447    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18448        fn resolve_child_environment(
18449            &self,
18450        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18451            Ok(vec![EnvMutation {
18452                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18453                value: None,
18454            }])
18455        }
18456    }
18457
18458    fn agent(value: &str) -> AgentId {
18459        AgentId::new(value).unwrap()
18460    }
18461
18462    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18463        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18464        let profile_revision =
18465            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18466        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18467            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18468            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18469            provider: agent("claude"),
18470            mode: SessionMode::Pty,
18471            terminal_size: gate4agent_types::TerminalSize {
18472                rows: 24,
18473                columns: 80,
18474            },
18475            prompt: None,
18476            bundle_id: None,
18477            context_id: None,
18478            environment_profile_id: Some(profile_id.clone()),
18479        };
18480        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18481        let catalog = active_registry().unwrap();
18482        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18483        let control = runtime.native_launch_profile_control();
18484        let node_profile = NodeEnvironmentProfile::new(
18485            profile_id.clone(),
18486            profile_revision,
18487            agent("claude"),
18488            [NativeLaunchProfile::new(
18489                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18490                agent("claude"),
18491                TransportKind::Pty,
18492                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18493                Arc::new(EmptyEnvironmentResolver),
18494            )
18495            .unwrap()],
18496        )
18497        .unwrap();
18498        let (binding, native_profiles, _materialization) = node_profile.into_parts();
18499        for native_profile in native_profiles {
18500            runtime.upsert_native_launch_profile(native_profile).unwrap();
18501        }
18502        let workspace = WorkspaceConfig::new(
18503            WorkspaceId::new("primary").unwrap(),
18504            std::env::current_dir().unwrap(),
18505        )
18506        .unwrap();
18507        let shared = NodeShared::new_with_incarnation(
18508            handle,
18509            "fixture-token".to_owned(),
18510            NodeId::new("node-station-knobs-test").unwrap(),
18511            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18512            vec![workspace],
18513            vec![agent("claude")],
18514            ProviderRuntimeStatuses::default(),
18515            None,
18516            Vec::new(),
18517            Vec::new(),
18518            spawn_profiles,
18519            Some(control),
18520            None,
18521            None,
18522            None,
18523            Vec::new(),
18524            Vec::new(),
18525            Vec::new(),
18526            Vec::new(),
18527            None,
18528            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18529        );
18530        shared
18531            .environment_profiles
18532            .write()
18533            .unwrap_or_else(|poisoned| poisoned.into_inner())
18534            .insert(binding.id.clone(), binding);
18535        (shared, spawn_defaults)
18536    }
18537
18538    /// Empty-default catalog refuses unknown network_allowlist ids.
18539    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
18540    #[test]
18541    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18542        let (shared, defaults) = station_knobs_fixture();
18543        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18544        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18545        let spec = crate::protocol::SpawnSpec {
18546            target: crate::protocol::SpawnTarget {
18547                node_id: shared.node_id.clone(),
18548                workspace_id: WorkspaceId::new("primary").unwrap(),
18549                worktree_id: None,
18550            },
18551            profile_id: defaults.profile_id.clone(),
18552            expected_profile_revision: defaults.revision.clone(),
18553            overrides: crate::protocol::SpawnOverrides {
18554                network_allowlist: Some(unknown),
18555                browser_profile_id: Some(browser),
18556                ..crate::protocol::SpawnOverrides::default()
18557            },
18558            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18559            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18560                "station-knobs-unknown-allowlist",
18561            )
18562            .unwrap(),
18563            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18564        };
18565        let resolved = spec.resolve(&defaults).unwrap();
18566        let failure = shared
18567            .resolve_environment_profile(&resolved)
18568            .expect_err("unknown allowlist must refuse");
18569        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18570    }
18571
18572    /// Registered network_allowlist (+ browser_profile_id when feature off)
18573    /// echo onto the env-profile receipt. Empty catalog still allows omit
18574    /// (None). Empty/whitespace refuse is type-level. Feature-off
18575    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
18576    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
18577    #[test]
18578    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18579        let (shared, defaults) = station_knobs_fixture();
18580        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18581        #[cfg(not(feature = "dig2-station-probe"))]
18582        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18583        shared
18584            .network_allowlist_catalog
18585            .write()
18586            .unwrap_or_else(|poisoned| poisoned.into_inner())
18587            .insert_id(allowlist.clone())
18588            .unwrap();
18589        let spec = crate::protocol::SpawnSpec {
18590            target: crate::protocol::SpawnTarget {
18591                node_id: shared.node_id.clone(),
18592                workspace_id: WorkspaceId::new("primary").unwrap(),
18593                worktree_id: None,
18594            },
18595            profile_id: defaults.profile_id.clone(),
18596            expected_profile_revision: defaults.revision.clone(),
18597            overrides: crate::protocol::SpawnOverrides {
18598                network_allowlist: Some(allowlist.clone()),
18599                #[cfg(not(feature = "dig2-station-probe"))]
18600                browser_profile_id: Some(browser.clone()),
18601                ..crate::protocol::SpawnOverrides::default()
18602            },
18603            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18604            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18605                .unwrap(),
18606            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18607        };
18608        let resolved = spec.resolve(&defaults).unwrap();
18609        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18610        #[cfg(not(feature = "dig2-station-probe"))]
18611        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18612        let receipt = shared
18613            .resolve_environment_profile(&resolved)
18614            .expect("resolve ok")
18615            .expect("env profile present");
18616        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18617        #[cfg(not(feature = "dig2-station-probe"))]
18618        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18619        #[cfg(feature = "dig2-station-probe")]
18620        assert_eq!(receipt.browser_profile_id, None);
18621        assert_eq!(receipt.profile_id.as_str(), "local-claude");
18622
18623        let omitted = crate::protocol::SpawnSpec {
18624            overrides: crate::protocol::SpawnOverrides::default(),
18625            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18626                .unwrap(),
18627            ..spec
18628        };
18629        let resolved_omit = omitted.resolve(&defaults).unwrap();
18630        let bare = shared
18631            .resolve_environment_profile(&resolved_omit)
18632            .expect("resolve ok")
18633            .expect("env profile present");
18634        assert_eq!(bare.network_allowlist, None);
18635        assert_eq!(bare.browser_profile_id, None);
18636    }
18637
18638    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
18639    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
18640    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
18641    /// unit-tested separately (probe refuses before lease on Linux).
18642    #[cfg(feature = "dig2-station-probe")]
18643    #[test]
18644    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18645        let (shared, defaults) = station_knobs_fixture();
18646        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18647        let spec = crate::protocol::SpawnSpec {
18648            target: crate::protocol::SpawnTarget {
18649                node_id: shared.node_id.clone(),
18650                workspace_id: WorkspaceId::new("primary").unwrap(),
18651                worktree_id: None,
18652            },
18653            profile_id: defaults.profile_id.clone(),
18654            expected_profile_revision: defaults.revision.clone(),
18655            overrides: crate::protocol::SpawnOverrides {
18656                browser_profile_id: Some(browser),
18657                ..crate::protocol::SpawnOverrides::default()
18658            },
18659            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18660            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18661                "station-knobs-probe-refuse",
18662            )
18663            .unwrap(),
18664            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18665        };
18666        let resolved = spec.resolve(&defaults).unwrap();
18667        let failure = shared
18668            .resolve_environment_profile(&resolved)
18669            .expect_err("browser_profile with failing probe must refuse");
18670        #[cfg(windows)]
18671        {
18672            assert!(
18673                matches!(
18674                    failure.code,
18675                    NodeFailureCode::BrowserStationUnreachable
18676                        | NodeFailureCode::BrowserStationProbeUnavailable
18677                ),
18678                "unexpected code {:?}",
18679                failure.code
18680            );
18681        }
18682        #[cfg(not(windows))]
18683        {
18684            assert_eq!(
18685                failure.code,
18686                NodeFailureCode::BrowserStationProbeUnavailable
18687            );
18688        }
18689    }
18690
18691    /// Feature on: omitting browser_profile_id still resolves (no probe).
18692    #[cfg(feature = "dig2-station-probe")]
18693    #[test]
18694    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18695        let (shared, defaults) = station_knobs_fixture();
18696        let spec = crate::protocol::SpawnSpec {
18697            target: crate::protocol::SpawnTarget {
18698                node_id: shared.node_id.clone(),
18699                workspace_id: WorkspaceId::new("primary").unwrap(),
18700                worktree_id: None,
18701            },
18702            profile_id: defaults.profile_id.clone(),
18703            expected_profile_revision: defaults.revision.clone(),
18704            overrides: crate::protocol::SpawnOverrides::default(),
18705            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18706            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18707                "station-knobs-probe-omit",
18708            )
18709            .unwrap(),
18710            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18711        };
18712        let resolved = spec.resolve(&defaults).unwrap();
18713        let receipt = shared
18714            .resolve_environment_profile(&resolved)
18715            .expect("omit browser_profile must not probe")
18716            .expect("env profile present");
18717        assert_eq!(receipt.browser_profile_id, None);
18718    }
18719
18720    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
18721    /// refuses a second holder for the same opaque profile id; release on
18722    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
18723    #[cfg(feature = "dig2-station-probe")]
18724    #[test]
18725    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18726        let (shared, _defaults) = station_knobs_fixture();
18727        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18728        let workspace_id = WorkspaceId::new("primary").unwrap();
18729        let address_a = SessionAddress {
18730            workspace_id: workspace_id.clone(),
18731            session: SessionKey {
18732                instance_id: AgentInstanceId(101),
18733                generation: SessionGeneration(1),
18734            },
18735        };
18736        let address_b = SessionAddress {
18737            workspace_id: workspace_id.clone(),
18738            session: SessionKey {
18739                instance_id: AgentInstanceId(102),
18740                generation: SessionGeneration(1),
18741            },
18742        };
18743        let guard_a = crate::dig2_station_lease::acquire_lease(
18744            &shared.browser_station_leases,
18745            profile.clone(),
18746            address_a.session.instance_id,
18747            1,
18748        )
18749        .expect("first lease");
18750        let busy = crate::dig2_station_lease::acquire_lease(
18751            &shared.browser_station_leases,
18752            profile.clone(),
18753            address_b.session.instance_id,
18754            2,
18755        )
18756        .expect_err("second holder must refuse");
18757        assert!(matches!(
18758            busy,
18759            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18760        ));
18761        // Simulate a bound session so remove_binding clears the lease.
18762        shared.bind_session_with_policy(
18763            &address_a,
18764            ProviderRuntimePolicy::raw_pty(),
18765            None,
18766        );
18767        guard_a.retain();
18768        assert!(shared.remove_binding(&address_a).is_some());
18769        crate::dig2_station_lease::acquire_lease(
18770            &shared.browser_station_leases,
18771            profile,
18772            address_b.session.instance_id,
18773            3,
18774        )
18775        .expect("reacquire after clear")
18776        .retain();
18777        assert!(
18778            crate::dig2_station_lease::release_lease_for_holder(
18779                &shared.browser_station_leases,
18780                address_b.session.instance_id,
18781            )
18782        );
18783    }
18784
18785    /// Feature-off: no browser_station_leases field; stub echo documented in
18786    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
18787    #[cfg(not(feature = "dig2-station-probe"))]
18788    #[test]
18789    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18790        let (shared, defaults) = station_knobs_fixture();
18791        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18792        let spec = crate::protocol::SpawnSpec {
18793            target: crate::protocol::SpawnTarget {
18794                node_id: shared.node_id.clone(),
18795                workspace_id: WorkspaceId::new("primary").unwrap(),
18796                worktree_id: None,
18797            },
18798            profile_id: defaults.profile_id.clone(),
18799            expected_profile_revision: defaults.revision.clone(),
18800            overrides: crate::protocol::SpawnOverrides {
18801                browser_profile_id: Some(browser.clone()),
18802                ..crate::protocol::SpawnOverrides::default()
18803            },
18804            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18805            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18806                .unwrap(),
18807            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18808        };
18809        let resolved = spec.resolve(&defaults).unwrap();
18810        let receipt = shared
18811            .resolve_environment_profile(&resolved)
18812            .expect("stub resolve")
18813            .expect("env profile");
18814        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18815    }
18816
18817
18818    /// Startup file loader registers ids into the empty-default catalog.
18819    /// Feature-off dig2 probe stays stub. Never secrets on C2.
18820    #[test]
18821    fn network_allowlist_catalog_loads_from_temp_file() {
18822        let unique = std::time::SystemTime::now()
18823            .duration_since(std::time::UNIX_EPOCH)
18824            .unwrap()
18825            .as_nanos();
18826        let path = std::env::temp_dir().join(format!(
18827            "gate4agent-node-allowlist-config-{}-{unique}.txt",
18828            std::process::id(),
18829        ));
18830        std::fs::write(
18831            &path,
18832            "# opaque station allowlist ids\negress-default\nlab-egress\n",
18833        )
18834        .unwrap();
18835        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18836            .expect("temp catalog file must load");
18837        let _ = std::fs::remove_file(&path);
18838        assert_eq!(loaded.len(), 2);
18839        let (shared, defaults) = station_knobs_fixture();
18840        {
18841            let mut catalog = shared
18842                .network_allowlist_catalog
18843                .write()
18844                .unwrap_or_else(|poisoned| poisoned.into_inner());
18845            *catalog = loaded;
18846        }
18847        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18848        let spec = crate::protocol::SpawnSpec {
18849            target: crate::protocol::SpawnTarget {
18850                node_id: shared.node_id.clone(),
18851                workspace_id: WorkspaceId::new("primary").unwrap(),
18852                worktree_id: None,
18853            },
18854            profile_id: defaults.profile_id.clone(),
18855            expected_profile_revision: defaults.revision.clone(),
18856            overrides: crate::protocol::SpawnOverrides {
18857                network_allowlist: Some(allowlist.clone()),
18858                ..crate::protocol::SpawnOverrides::default()
18859            },
18860            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18861            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18862                "station-knobs-catalog-file",
18863            )
18864            .unwrap(),
18865            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18866        };
18867        let resolved = spec.resolve(&defaults).unwrap();
18868        let receipt = shared
18869            .resolve_environment_profile(&resolved)
18870            .expect("registered via file load")
18871            .expect("env profile present");
18872        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18873    }
18874
18875    /// Snapshot launch inventory echoes registered catalog ids (opaque).
18876    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
18877    #[test]
18878    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18879        let (shared, _defaults) = station_knobs_fixture();
18880        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18881        shared
18882            .network_allowlist_catalog
18883            .write()
18884            .unwrap_or_else(|poisoned| poisoned.into_inner())
18885            .insert_id(allowlist.clone())
18886            .unwrap();
18887        let snapshot = shared.snapshot();
18888        let inventory = snapshot
18889            .launch_inventory
18890            .expect("launch inventory present");
18891        let ids = inventory
18892            .network_allowlists
18893            .expect("non-empty catalog surfaces ids");
18894        assert_eq!(ids, vec![allowlist]);
18895    }
18896
18897    /// Track B: registered id with provider_native codex_network_access refuses
18898    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
18899    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
18900    #[test]
18901    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18902        let (shared, defaults) = station_knobs_fixture();
18903        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18904        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18905            id: allowlist.clone(),
18906            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18907                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18908                peer: "127.0.0.1:443".to_owned(),
18909            }],
18910            provider_native: Some(
18911                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18912                    codex_network_access: Some(true),
18913                },
18914            ),
18915        };
18916        shared
18917            .network_allowlist_catalog
18918            .write()
18919            .unwrap_or_else(|poisoned| poisoned.into_inner())
18920            .insert(entry)
18921            .unwrap();
18922        let spec = crate::protocol::SpawnSpec {
18923            target: crate::protocol::SpawnTarget {
18924                node_id: shared.node_id.clone(),
18925                workspace_id: WorkspaceId::new("primary").unwrap(),
18926                worktree_id: None,
18927            },
18928            profile_id: defaults.profile_id.clone(),
18929            expected_profile_revision: defaults.revision.clone(),
18930            overrides: crate::protocol::SpawnOverrides {
18931                network_allowlist: Some(allowlist),
18932                ..crate::protocol::SpawnOverrides::default()
18933            },
18934            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18935            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18936                "station-knobs-unsupported-mapping",
18937            )
18938            .unwrap(),
18939            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18940        };
18941        let resolved = spec.resolve(&defaults).unwrap();
18942        assert_eq!(resolved.provider.as_str(), "claude");
18943        let failure = shared
18944            .resolve_environment_profile(&resolved)
18945            .expect_err("claude + codex_network_access must refuse");
18946        assert_eq!(
18947            failure.code,
18948            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18949        );
18950    }
18951
18952    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
18953    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
18954    /// Never secrets on C2.
18955    #[test]
18956    fn resolve_codex_network_access_overlay_moderate_only() {
18957        let (shared, defaults) = station_knobs_fixture();
18958        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18959        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18960            id: allowlist.clone(),
18961            permits: Vec::new(),
18962            provider_native: Some(
18963                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18964                    codex_network_access: Some(true),
18965                },
18966            ),
18967        };
18968        shared
18969            .network_allowlist_catalog
18970            .write()
18971            .unwrap_or_else(|poisoned| poisoned.into_inner())
18972            .insert(entry)
18973            .unwrap();
18974
18975        let overlay = shared
18976            .resolve_codex_network_access_overlay_args(
18977                &agent("codex"),
18978                SessionMode::Pty,
18979                ApprovalLevel::Moderate,
18980                Some(&allowlist),
18981            )
18982            .expect("Moderate Codex maps network_access");
18983        assert_eq!(
18984            overlay,
18985            vec![
18986                std::ffi::OsString::from("-c"),
18987                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18988            ]
18989        );
18990
18991        let acp = shared
18992            .resolve_codex_network_access_overlay_args(
18993                &agent("codex"),
18994                SessionMode::Acp,
18995                ApprovalLevel::Moderate,
18996                Some(&allowlist),
18997            )
18998            .unwrap();
18999        assert_eq!(acp, overlay);
19000
19001        let inline = shared
19002            .resolve_codex_network_access_overlay_args(
19003                &agent("codex"),
19004                SessionMode::Inline,
19005                ApprovalLevel::Moderate,
19006                Some(&allowlist),
19007            )
19008            .unwrap_err();
19009        assert_eq!(
19010            inline.code,
19011            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19012        );
19013
19014        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19015            let spec = crate::protocol::SpawnSpec {
19016                target: crate::protocol::SpawnTarget {
19017                    node_id: shared.node_id.clone(),
19018                    workspace_id: WorkspaceId::new("primary").unwrap(),
19019                    worktree_id: None,
19020                },
19021                profile_id: defaults.profile_id.clone(),
19022                expected_profile_revision: defaults.revision.clone(),
19023                overrides: crate::protocol::SpawnOverrides {
19024                    provider: crate::protocol::SpawnOverride::Set {
19025                        value: agent("codex"),
19026                    },
19027                    approval_level: Some(level),
19028                    network_allowlist: Some(allowlist.clone()),
19029                    ..crate::protocol::SpawnOverrides::default()
19030                },
19031                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19032                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19033                    "codex-net-refuse-{}",
19034                    match level {
19035                        ApprovalLevel::ReadOnly => "ro",
19036                        ApprovalLevel::FullAuto => "fa",
19037                        _ => "other",
19038                    }
19039                ))
19040                .unwrap(),
19041                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19042            };
19043            let resolved = spec.resolve(&defaults).unwrap();
19044            let failure = shared
19045                .resolve_environment_profile(&resolved)
19046                .expect_err("RO/FullAuto must refuse");
19047            assert_eq!(
19048                failure.code,
19049                NodeFailureCode::UnsupportedNetworkAllowlistMapping
19050            );
19051        }
19052    }
19053
19054    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
19055    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
19056    /// Never secrets on C2; does not invent provider_native Claude keys.
19057    #[test]
19058    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19059        let (shared, _defaults) = station_knobs_fixture();
19060        let allowlist =
19061            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19062        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19063            id: allowlist.clone(),
19064            permits: vec![
19065                crate::network_allowlist_catalog::NetworkPermitSketch {
19066                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19067                    peer: "github.com:443".to_owned(),
19068                },
19069                crate::network_allowlist_catalog::NetworkPermitSketch {
19070                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19071                    peer: "*.npmjs.org".to_owned(),
19072                },
19073            ],
19074            provider_native: None,
19075        };
19076        shared
19077            .network_allowlist_catalog
19078            .write()
19079            .unwrap_or_else(|poisoned| poisoned.into_inner())
19080            .insert(entry)
19081            .unwrap();
19082
19083        let unique = std::time::SystemTime::now()
19084            .duration_since(std::time::UNIX_EPOCH)
19085            .unwrap()
19086            .as_nanos();
19087        let provider_home = std::env::temp_dir().join(format!(
19088            "gate4agent-claude-provider-home-{}-{unique}",
19089            std::process::id(),
19090        ));
19091        std::fs::create_dir_all(&provider_home).unwrap();
19092
19093        let overlay = shared
19094            .resolve_network_allowlist_overlay_args(
19095                &agent("claude"),
19096                SessionMode::Pty,
19097                ApprovalLevel::Moderate,
19098                Some(&allowlist),
19099                Some(provider_home.as_path()),
19100            )
19101            .expect("Claude PTY settings overlay on unix");
19102        let settings_path = provider_home.join(
19103            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19104        );
19105        assert_eq!(
19106            overlay,
19107            vec![
19108                std::ffi::OsString::from("--settings"),
19109                std::ffi::OsString::from(settings_path.as_os_str()),
19110            ]
19111        );
19112        let body = std::fs::read_to_string(&settings_path).unwrap();
19113        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19114        assert_eq!(value["sandbox"]["enabled"], true);
19115        assert_eq!(
19116            value["sandbox"]["network"]["allowedDomains"],
19117            serde_json::json!(["github.com", "*.npmjs.org"])
19118        );
19119        assert!(settings_path.starts_with(&provider_home));
19120
19121        let acp = shared
19122            .resolve_network_allowlist_overlay_args(
19123                &agent("claude"),
19124                SessionMode::Acp,
19125                ApprovalLevel::Moderate,
19126                Some(&allowlist),
19127                Some(provider_home.as_path()),
19128            )
19129            .expect_err("Claude ACP must refuse settings overlay");
19130        assert_eq!(
19131            acp.code,
19132            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19133        );
19134
19135        let no_home = shared
19136            .resolve_network_allowlist_overlay_args(
19137                &agent("claude"),
19138                SessionMode::Pty,
19139                ApprovalLevel::Moderate,
19140                Some(&allowlist),
19141                None,
19142            )
19143            .expect_err("Claude overlay requires ProviderHome");
19144        assert_eq!(
19145            no_home.code,
19146            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19147        );
19148
19149        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19150        shared
19151            .network_allowlist_catalog
19152            .write()
19153            .unwrap_or_else(|poisoned| poisoned.into_inner())
19154            .insert_id(bare.clone())
19155            .unwrap();
19156        let empty = shared
19157            .resolve_network_allowlist_overlay_args(
19158                &agent("claude"),
19159                SessionMode::Pty,
19160                ApprovalLevel::Moderate,
19161                Some(&bare),
19162                Some(provider_home.as_path()),
19163            )
19164            .unwrap();
19165        assert!(empty.is_empty());
19166
19167        let _ = std::fs::remove_dir_all(&provider_home);
19168    }
19169
19170    /// Kimi / Grok with permits stay without invented argv overlays.
19171    #[test]
19172    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19173        let (shared, _defaults) = station_knobs_fixture();
19174        let allowlist =
19175            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19176        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19177            id: allowlist.clone(),
19178            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19179                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19180                peer: "example.com:443".to_owned(),
19181            }],
19182            provider_native: None,
19183        };
19184        shared
19185            .network_allowlist_catalog
19186            .write()
19187            .unwrap_or_else(|poisoned| poisoned.into_inner())
19188            .insert(entry)
19189            .unwrap();
19190        for provider in ["kimi", "grok"] {
19191            let overlay = shared
19192                .resolve_network_allowlist_overlay_args(
19193                    &agent(provider),
19194                    SessionMode::Pty,
19195                    ApprovalLevel::Moderate,
19196                    Some(&allowlist),
19197                    None,
19198                )
19199                .unwrap();
19200            assert!(
19201                overlay.is_empty(),
19202                "{provider} must not invent network argv from permits"
19203            );
19204        }
19205    }
19206
19207    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
19208    #[test]
19209    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19210        let unique = std::time::SystemTime::now()
19211            .duration_since(std::time::UNIX_EPOCH)
19212            .unwrap()
19213            .as_nanos();
19214        let path = std::env::temp_dir().join(format!(
19215            "gate4agent-node-allowlist-json-{}-{unique}.json",
19216            std::process::id(),
19217        ));
19218        std::fs::write(
19219            &path,
19220            r#"{
19221              "schema_version": 2,
19222              "entries": [
19223                {
19224                  "id": "egress-default",
19225                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19226                },
19227                { "id": "lab-egress" }
19228              ]
19229            }"#,
19230        )
19231        .unwrap();
19232        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19233            .expect("json v2 catalog must load");
19234        let _ = std::fs::remove_file(&path);
19235        assert_eq!(loaded.len(), 2);
19236        let entry = loaded
19237            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19238            .unwrap();
19239        assert_eq!(entry.permits.len(), 1);
19240        let (shared, defaults) = station_knobs_fixture();
19241        {
19242            let mut catalog = shared
19243                .network_allowlist_catalog
19244                .write()
19245                .unwrap_or_else(|poisoned| poisoned.into_inner());
19246            *catalog = loaded;
19247        }
19248        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19249        let spec = crate::protocol::SpawnSpec {
19250            target: crate::protocol::SpawnTarget {
19251                node_id: shared.node_id.clone(),
19252                workspace_id: WorkspaceId::new("primary").unwrap(),
19253                worktree_id: None,
19254            },
19255            profile_id: defaults.profile_id.clone(),
19256            expected_profile_revision: defaults.revision.clone(),
19257            overrides: crate::protocol::SpawnOverrides {
19258                network_allowlist: Some(allowlist.clone()),
19259                ..crate::protocol::SpawnOverrides::default()
19260            },
19261            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19262            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19263                "station-knobs-catalog-json",
19264            )
19265            .unwrap(),
19266            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19267        };
19268        let resolved = spec.resolve(&defaults).unwrap();
19269        let receipt = shared
19270            .resolve_environment_profile(&resolved)
19271            .expect("permits-only entry ok for claude")
19272            .expect("env profile present");
19273        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19274        let inventory = shared
19275            .snapshot()
19276            .launch_inventory
19277            .expect("launch inventory present");
19278        let ids = inventory.network_allowlists.expect("ids surface");
19279        assert_eq!(
19280            ids,
19281            vec![
19282                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19283                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19284            ]
19285        );
19286    }
19287
19288
19289}
19290
19291
19292#[cfg(test)]
19293mod workspace_environment_collision_tests {
19294    use super::*;
19295    use crate::session_environment::{
19296        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19297    };
19298    use gate4agent_catalog::EnvMutation;
19299    use gate4agent_runtime_native::{
19300        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19301        NativeLaunchProfileId,
19302    };
19303    use std::ffi::OsString;
19304    use std::sync::Arc;
19305
19306    struct EmptyEnvironmentResolver;
19307
19308    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19309        fn resolve_child_environment(
19310            &self,
19311        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19312            Ok(vec![EnvMutation {
19313                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19314                value: None,
19315            }])
19316        }
19317    }
19318
19319    fn agent(value: &str) -> AgentId {
19320        AgentId::new(value).unwrap()
19321    }
19322
19323    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
19324    fn claude_config_dir_collision_fixture() -> (
19325        NodeShared,
19326        ResolvedEnvironmentProfileReceipt,
19327        ResolvedEnvironmentProfileReceipt,
19328    ) {
19329        let catalog = active_registry().unwrap();
19330        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19331        let control = runtime.native_launch_profile_control();
19332        let workspace = WorkspaceConfig::new(
19333            WorkspaceId::new("primary").unwrap(),
19334            std::env::current_dir().unwrap(),
19335        )
19336        .unwrap();
19337        let shared = NodeShared::new_with_incarnation(
19338            handle,
19339            "fixture-token".to_owned(),
19340            NodeId::new("node-env-collision-test").unwrap(),
19341            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19342            vec![workspace],
19343            vec![agent("claude")],
19344            ProviderRuntimeStatuses::default(),
19345            None,
19346            Vec::new(),
19347            Vec::new(),
19348            SpawnProfileRegistry::default(),
19349            Some(control),
19350            None,
19351            None,
19352            None,
19353            Vec::new(),
19354            Vec::new(),
19355            Vec::new(),
19356            Vec::new(),
19357            None,
19358            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19359        );
19360
19361        let mut receipts = Vec::new();
19362        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19363            let profile_id =
19364                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19365            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19366                "claude-config-dir-{suffix}-r1"
19367            ))
19368            .unwrap();
19369            let materialization = NodeSessionMaterializationProfile::new(
19370                Vec::new(),
19371                vec![NodeSessionPathBinding::new(
19372                    "CLAUDE_CONFIG_DIR",
19373                    NodeSessionPathClass::ProviderHome,
19374                )
19375                .unwrap()],
19376                Vec::new(),
19377            )
19378            .unwrap();
19379            let node_profile = NodeEnvironmentProfile::new_with_materialization(
19380                profile_id.clone(),
19381                profile_revision.clone(),
19382                agent("claude"),
19383                [NativeLaunchProfile::new(
19384                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19385                        .unwrap(),
19386                    agent("claude"),
19387                    TransportKind::Pty,
19388                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19389                    Arc::new(EmptyEnvironmentResolver),
19390                )
19391                .unwrap()],
19392                Some(materialization),
19393            )
19394            .unwrap();
19395            let (binding, native_profiles, materialization) = node_profile.into_parts();
19396            for native_profile in native_profiles {
19397                runtime.upsert_native_launch_profile(native_profile).unwrap();
19398            }
19399            shared
19400                .environment_profiles
19401                .write()
19402                .unwrap_or_else(|poisoned| poisoned.into_inner())
19403                .insert(binding.id.clone(), binding);
19404            shared
19405                .environment_materialization_profiles
19406                .write()
19407                .unwrap_or_else(|poisoned| poisoned.into_inner())
19408                .insert(profile_id.clone(), materialization.unwrap());
19409            receipts.push(ResolvedEnvironmentProfileReceipt {
19410                profile_id,
19411                profile_revision,
19412                network_allowlist: None,
19413                browser_profile_id: None,
19414            });
19415        }
19416        let profile_b = receipts.pop().unwrap();
19417        let profile_a = receipts.pop().unwrap();
19418        (shared, profile_a, profile_b)
19419    }
19420
19421    #[test]
19422    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19423        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19424        let primary = WorkspaceId::new("primary").unwrap();
19425        let worktree = WorkspaceId::new("worktree-b").unwrap();
19426        let address = SessionAddress {
19427            workspace_id: primary.clone(),
19428            session: SessionKey {
19429                instance_id: AgentInstanceId(41),
19430                generation: SessionGeneration(1),
19431            },
19432        };
19433        shared.bind_session_with_policy(
19434            &address,
19435            ProviderRuntimePolicy::raw_pty(),
19436            Some(profile_a.clone()),
19437        );
19438
19439        let refused = shared
19440            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19441            .unwrap_err();
19442        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19443
19444        // Some vs None on the same cwd is also a collision.
19445        let refused_none = shared
19446            .ensure_workspace_environment_profile_compatible(&primary, None)
19447            .unwrap_err();
19448        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19449
19450        // Same profile on the same workspace remains allowed.
19451        shared
19452            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19453            .unwrap();
19454
19455        // Distinct workspace id (managed-worktree parallel path) allows profile B.
19456        shared
19457            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19458            .unwrap();
19459    }
19460
19461    #[tokio::test]
19462    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19463        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19464        let primary = WorkspaceId::new("primary").unwrap();
19465        let address = SessionAddress {
19466            workspace_id: primary.clone(),
19467            session: SessionKey {
19468                instance_id: AgentInstanceId(41),
19469                generation: SessionGeneration(1),
19470            },
19471        };
19472        shared.bind_session_with_policy(
19473            &address,
19474            ProviderRuntimePolicy::raw_pty(),
19475            Some(profile_a),
19476        );
19477
19478        let failure = shared
19479            .spawn_session_with_deadline(
19480                primary,
19481                agent("claude"),
19482                SessionMode::Pty,
19483                gate4agent_types::TerminalSize {
19484                    rows: 24,
19485                    columns: 80,
19486                },
19487                None,
19488                Some(profile_b),
19489                None,
19490                None,
19491                None,
19492                None,
19493                SpawnRecordPolicy::ProviderIdentityOnly,
19494                Some(Instant::now() + Duration::from_secs(30)),
19495                &[],
19496                None,
19497                ApprovalLevel::default(),
19498                None,
19499                None,
19500            )
19501            .await
19502            .unwrap_err();
19503        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19504        // Guard runs before instance id mint / bind.
19505        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19506        assert!(shared.handle.snapshot().sessions.is_empty());
19507    }
19508}
19509
19510#[cfg(test)]
19511mod spawn_characterization_tests {
19512    use super::*;
19513    use crate::session_environment::{
19514        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19515        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19516        NodeSessionPathClass,
19517    };
19518    use gate4agent_catalog::EnvMutation;
19519    use gate4agent_runtime_native::{
19520        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19521        NativeLaunchProfile, NativeLaunchProfileId,
19522    };
19523    use std::ffi::OsString;
19524    use std::sync::Arc;
19525
19526    struct EmptyEnvironmentResolver;
19527
19528    struct FixtureSecretResolver {
19529        deny: Arc<AtomicBool>,
19530    }
19531
19532    impl NodeSecretResolver for FixtureSecretResolver {
19533        fn resolve(
19534            &self,
19535            _reference: &NodeSecretReference,
19536        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19537            if self.deny.load(Ordering::Acquire) {
19538                Err(NodeSecretResolveError::Denied)
19539            } else {
19540                NodeSecretValue::text("fixture-secret")
19541                    .map_err(|_| NodeSecretResolveError::Unavailable)
19542            }
19543        }
19544    }
19545
19546    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19547        fn resolve_child_environment(
19548            &self,
19549        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19550            Ok(vec![EnvMutation {
19551                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19552                value: None,
19553            }])
19554        }
19555    }
19556
19557    fn agent(value: &str) -> AgentId {
19558        AgentId::new(value).unwrap()
19559    }
19560
19561    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19562        let unique = std::time::SystemTime::now()
19563            .duration_since(std::time::UNIX_EPOCH)
19564            .unwrap()
19565            .as_nanos();
19566        std::env::temp_dir().join(format!(
19567            "gate4agent-node-{label}-{}-{unique}",
19568            std::process::id(),
19569        ))
19570    }
19571
19572    fn materialization_test_shared(
19573        deny_secret: bool,
19574    ) -> (
19575        NodeShared,
19576        NativeRuntime,
19577        ResolvedEnvironmentProfileReceipt,
19578        PathBuf,
19579        Arc<AtomicBool>,
19580    ) {
19581        let workspace = WorkspaceConfig::new(
19582            WorkspaceId::new("primary").unwrap(),
19583            std::env::current_dir().unwrap(),
19584        )
19585        .unwrap();
19586        materialization_test_shared_with_workspace(deny_secret, workspace)
19587    }
19588
19589    fn materialization_test_shared_with_workspace(
19590        deny_secret: bool,
19591        workspace: WorkspaceConfig,
19592    ) -> (
19593        NodeShared,
19594        NativeRuntime,
19595        ResolvedEnvironmentProfileReceipt,
19596        PathBuf,
19597        Arc<AtomicBool>,
19598    ) {
19599        let root = temporary_workspace_root(if deny_secret {
19600            "materialization-denied"
19601        } else {
19602            "materialization-ready"
19603        });
19604        std::fs::create_dir_all(&root).unwrap();
19605        let materialization_root = root.join("session-environments");
19606        let deny = Arc::new(AtomicBool::new(deny_secret));
19607        let resolver = Arc::new(FixtureSecretResolver {
19608            deny: Arc::clone(&deny),
19609        });
19610        let materializer = SessionEnvironmentMaterializer::new(
19611            materialization_root,
19612            resolver,
19613        )
19614        .unwrap();
19615        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19616        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19617            "materialized-claude-r1",
19618        )
19619        .unwrap();
19620        let materialization = NodeSessionMaterializationProfile::new(
19621            vec![NodeSessionEnvironmentMutation::SetSecret {
19622                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19623                reference: NodeSecretReference::new("fixture-token").unwrap(),
19624            }],
19625            vec![NodeSessionPathBinding::new(
19626                "GATE4AGENT_PROVIDER_HOME",
19627                NodeSessionPathClass::ProviderHome,
19628            )
19629            .unwrap()],
19630            vec![NodeSessionFile::secret(
19631                NodeSessionPathClass::Config,
19632                "auth/token",
19633                NodeSecretReference::new("fixture-token").unwrap(),
19634            )
19635            .unwrap()],
19636        )
19637        .unwrap();
19638        let catalog = active_registry().unwrap();
19639        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19640        let control = runtime.native_launch_profile_control();
19641        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19642            profile_id.clone(),
19643            profile_revision.clone(),
19644            agent("claude"),
19645            [NativeLaunchProfile::new(
19646                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19647                agent("claude"),
19648                TransportKind::Pty,
19649                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19650                Arc::new(EmptyEnvironmentResolver),
19651            )
19652            .unwrap()],
19653            Some(materialization),
19654        )
19655        .unwrap();
19656        let (binding, native_profiles, materialization) = node_profile.into_parts();
19657        for native_profile in native_profiles {
19658            runtime.upsert_native_launch_profile(native_profile).unwrap();
19659        }
19660        let shared = NodeShared::new_with_incarnation(
19661            handle,
19662            "fixture-token".to_owned(),
19663            NodeId::new("node-materialization-test").unwrap(),
19664            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19665            vec![workspace],
19666            vec![agent("claude")],
19667            ProviderRuntimeStatuses::default(),
19668            None,
19669            Vec::new(),
19670            Vec::new(),
19671            SpawnProfileRegistry::default(),
19672            Some(control),
19673            None,
19674            Some(materializer),
19675            None,
19676            Vec::new(),
19677            Vec::new(),
19678            Vec::new(),
19679            Vec::new(),
19680            None,
19681            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19682        );
19683        shared
19684            .environment_profiles
19685            .write()
19686            .unwrap_or_else(|poisoned| poisoned.into_inner())
19687            .insert(binding.id.clone(), binding);
19688        shared
19689            .environment_materialization_profiles
19690            .write()
19691            .unwrap_or_else(|poisoned| poisoned.into_inner())
19692            .insert(profile_id.clone(), materialization.unwrap());
19693        (
19694            shared,
19695            runtime,
19696            ResolvedEnvironmentProfileReceipt {
19697                profile_id,
19698                profile_revision,
19699                network_allowlist: None,
19700                browser_profile_id: None,
19701            },
19702            root,
19703            deny,
19704        )
19705    }
19706
19707
19708    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
19709
19710    async fn characterization_spawn(
19711        shared: &NodeShared,
19712        mode: SessionMode,
19713        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19714        required: &[ProviderRuntimeCapability],
19715        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19716    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19717        shared
19718            .spawn_session_with_deadline(
19719                WorkspaceId::new("primary").unwrap(),
19720                agent("claude"),
19721                mode,
19722                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19723                None,
19724                environment_profile,
19725                None,
19726                None,
19727                None,
19728                Some(ProviderRuntimePolicy::raw_pty()),
19729                SpawnRecordPolicy::ProviderIdentityOnly,
19730                Some(Instant::now() + Duration::from_secs(30)),
19731                required,
19732                harness_mcp,
19733                ApprovalLevel::default(),
19734                None,
19735                None,
19736            )
19737            .await
19738    }
19739
19740    fn assert_no_session_state(shared: &NodeShared) {
19741        assert!(shared.handle.snapshot().sessions.is_empty());
19742        assert!(shared
19743            .session_bindings
19744            .lock()
19745            .unwrap_or_else(|poisoned| poisoned.into_inner())
19746            .is_empty());
19747        assert!(shared
19748            .materializations
19749            .lock()
19750            .unwrap_or_else(|poisoned| poisoned.into_inner())
19751            .is_empty());
19752    }
19753
19754    #[tokio::test]
19755    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19756        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19757        let error = characterization_spawn(
19758            &shared,
19759            SessionMode::Pty,
19760            None,
19761            &[ProviderRuntimeCapability::SemanticReadiness],
19762            None,
19763        )
19764        .await
19765        .unwrap_err();
19766        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19767        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19768        assert_no_session_state(&shared);
19769        drop(shared);
19770        drop(runtime);
19771        std::fs::remove_dir_all(root).unwrap();
19772    }
19773
19774    #[tokio::test]
19775    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19776        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19777        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19778            .await
19779            .unwrap_err();
19780        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19781        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19782        assert_no_session_state(&shared);
19783        drop(shared);
19784        drop(runtime);
19785        std::fs::remove_dir_all(root).unwrap();
19786    }
19787
19788    #[test]
19789    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19790        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19791        let address = SessionAddress {
19792            workspace_id: WorkspaceId::new("primary").unwrap(),
19793            session: SessionKey {
19794                instance_id: AgentInstanceId(77),
19795                generation: SessionGeneration(1),
19796            },
19797        };
19798        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19799        let removed = shared.remove_binding(&address).expect("binding removed");
19800        assert!(removed.environment_profile.is_some());
19801        assert!(shared.remove_binding(&address).is_none());
19802        assert_no_session_state(&shared);
19803        drop(shared);
19804        drop(runtime);
19805        std::fs::remove_dir_all(root).unwrap();
19806    }
19807
19808    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19809        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19810        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19811        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19812            .expect("review current test executable as fixture helper");
19813        let registry = HarnessMcpProxyRegistry::new(helper);
19814        let spec = crate::protocol::SpawnSpec {
19815            target: crate::protocol::SpawnTarget {
19816                node_id: NodeId::new("node-characterization").unwrap(),
19817                workspace_id: WorkspaceId::new("primary").unwrap(),
19818                worktree_id: None,
19819            },
19820            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19821            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19822            overrides: crate::protocol::SpawnOverrides::default(),
19823            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19824            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19825            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19826        };
19827        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19828        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19829        let launch = HarnessMcpLaunchV1 {
19830            server_name: "fixture-harness".to_owned(),
19831            args: vec!["--session-proxy".to_owned()],
19832            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19833            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19834            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19835            trace: None,
19836            scrub_env: Vec::new(),
19837        };
19838        let expires = unix_time_ms().saturating_add(30_000);
19839        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19840        registry
19841            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19842            .unwrap()
19843    }
19844
19845    #[tokio::test]
19846    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19847        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19848        let prepared = characterization_prepared_harness("claude").await;
19849        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19850            .await
19851            .unwrap_err();
19852        // Inline is stopped by runtime admission before the harness check runs.
19853        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19854        let mismatched = characterization_prepared_harness("codex").await;
19855        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19856            .await
19857            .unwrap_err();
19858        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19859        assert_no_session_state(&shared);
19860        drop(shared);
19861        drop(runtime);
19862        std::fs::remove_dir_all(root).unwrap();
19863    }
19864
19865    #[tokio::test]
19866    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19867        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19868        shared.native_launch_profile_control = None;
19869        let prepared = characterization_prepared_harness("claude").await;
19870        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19871            .await
19872            .unwrap_err();
19873        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19874        assert_no_session_state(&shared);
19875        drop(shared);
19876        drop(runtime);
19877        std::fs::remove_dir_all(root).unwrap();
19878    }
19879}
19880
19881#[cfg(all(test, windows))]
19882mod tests {
19883    use super::*;
19884    use gate4agent_types::ProviderActivity;
19885    use crate::session_environment::{
19886        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19887        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19888        NodeSessionPathClass,
19889    };
19890    use gate4agent_catalog::EnvMutation;
19891    use gate4agent_runtime_native::{
19892        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19893        NativeLaunchProfile, NativeLaunchProfileId,
19894    };
19895    use std::ffi::OsString;
19896    use std::sync::Arc;
19897
19898    struct EmptyEnvironmentResolver;
19899
19900    struct FixtureSecretResolver {
19901        deny: Arc<AtomicBool>,
19902    }
19903
19904    impl NodeSecretResolver for FixtureSecretResolver {
19905        fn resolve(
19906            &self,
19907            _reference: &NodeSecretReference,
19908        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19909            if self.deny.load(Ordering::Acquire) {
19910                Err(NodeSecretResolveError::Denied)
19911            } else {
19912                NodeSecretValue::text("fixture-secret")
19913                    .map_err(|_| NodeSecretResolveError::Unavailable)
19914            }
19915        }
19916    }
19917
19918    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19919        fn resolve_child_environment(
19920            &self,
19921        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19922            Ok(vec![EnvMutation {
19923                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19924                value: None,
19925            }])
19926        }
19927    }
19928
19929    fn agent(value: &str) -> AgentId {
19930        AgentId::new(value).unwrap()
19931    }
19932
19933    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19934        let catalog = active_registry().unwrap();
19935        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19936        let workspace = WorkspaceConfig::new(
19937            WorkspaceId::new("primary").unwrap(),
19938            std::env::current_dir().unwrap(),
19939        )
19940        .unwrap()
19941        .with_worktree_service_mode(mode);
19942        NodeShared::new(
19943            handle,
19944            "fixture-token".to_owned(),
19945            NodeId::new("node-terminal-test").unwrap(),
19946            vec![workspace],
19947            vec![agent("claude")],
19948        )
19949    }
19950
19951    fn terminal_test_shared() -> NodeShared {
19952        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19953    }
19954
19955    #[cfg(feature = "fixture")]
19956    #[test]
19957    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19958        let probe = SpawnManagedWorktreeV2FailureProbe::default();
19959        assert_eq!(probe.latest_code(), None);
19960
19961        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19962        assert_eq!(
19963            probe.latest_code(),
19964            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19965        );
19966
19967        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19968        assert_eq!(
19969            probe.latest_code(),
19970            Some(NodeFailureCode::SpawnDeadlineExceeded),
19971        );
19972
19973        probe.record(None);
19974        assert_eq!(probe.latest_code(), None);
19975    }
19976
19977    #[test]
19978    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19979        let source = temporary_workspace_root("launch-inventory-source");
19980        let allocation = temporary_workspace_root("launch-inventory-allocation");
19981        std::fs::create_dir_all(&source).unwrap();
19982        std::fs::create_dir_all(&allocation).unwrap();
19983        let workspace_id = WorkspaceId::new("primary").unwrap();
19984        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19985            .unwrap()
19986            .with_worktree_service_mode(WorktreeServiceMode::Managed);
19987        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19988            workspace = workspace
19989                .with_managed_worktree_profile(
19990                    ManagedWorktreeProfile::new(
19991                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19992                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19993                        &allocation,
19994                        "gate4agent",
19995                        "HEAD",
19996                        ManagedWorktreeRetention::RemoveWhenReleased,
19997                    )
19998                    .unwrap(),
19999                )
20000                .unwrap();
20001        }
20002        let overflow = workspace.clone().with_managed_worktree_profile(
20003            ManagedWorktreeProfile::new(
20004                WorktreeProfileId::new("overflow").unwrap(),
20005                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20006                &allocation,
20007                "gate4agent",
20008                "HEAD",
20009                ManagedWorktreeRetention::Retain,
20010            )
20011            .unwrap(),
20012        );
20013        assert!(matches!(
20014            overflow,
20015            Err(NodeServerError::ManagedWorktreeProfileCapacity {
20016                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20017                ..
20018            }),
20019        ));
20020
20021        let catalog = active_registry().unwrap();
20022        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20023        let shared = NodeShared::new(
20024            handle,
20025            "fixture-token".to_owned(),
20026            NodeId::new("node-launch-inventory").unwrap(),
20027            vec![workspace],
20028            vec![agent("claude")],
20029        );
20030        let snapshot = shared.snapshot();
20031        let launch = snapshot.launch_inventory.as_ref().unwrap();
20032        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20033        assert!(launch.bundles.as_ref().unwrap().is_empty());
20034        let profiles = snapshot.workspaces[0]
20035            .managed_worktree_profiles
20036            .as_ref()
20037            .unwrap();
20038        assert_eq!(
20039            snapshot.workspaces[0].worktree_service_mode,
20040            Some(WorktreeServiceMode::Managed),
20041        );
20042        assert_eq!(
20043            profiles.profiles.len(),
20044            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20045        );
20046        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20047        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20048            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20049        }
20050        let legacy = snapshot_for_wire(
20051            &shared,
20052            true,
20053            true,
20054            false,
20055            false,
20056            true,
20057            false,
20058            true,
20059            false,
20060            true,
20061        );
20062        assert!(legacy.launch_inventory.is_none());
20063        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20064
20065        drop(shared);
20066        std::fs::remove_dir_all(source).unwrap();
20067        std::fs::remove_dir_all(allocation).unwrap();
20068    }
20069
20070    #[test]
20071    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20072        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20073            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20074        }));
20075        let source = gate4agent_types::ProviderSource {
20076            family: AdapterFamily::PtySemantic,
20077            binding: AdapterBinding::new(
20078                gate4agent_types::AdapterId::new("claude").unwrap(),
20079                "fixture/v1",
20080                gate4agent_types::AdapterVerification::SyntheticFixture,
20081            )
20082            .unwrap(),
20083        };
20084        let provider = ProviderSnapshot {
20085            sequence: 17,
20086            session: Some(ProviderSessionIdentity {
20087                key: ProviderSessionKey::SessionId,
20088                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20089                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20090            }),
20091            model: Some("PRIVATE_MODEL".to_owned()),
20092            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20093            completed_turns: 3,
20094            usage: gate4agent_types::TokenUsage {
20095                input_tokens: 10,
20096                output_tokens: 20,
20097                cache_read_tokens: 30,
20098                cache_write_tokens: 40,
20099                reasoning_tokens: 50,
20100                context_window: Some(200_000),
20101            },
20102            lead_activity: ProviderActivity::WaitingForInput,
20103            activity: ProviderActivity::WaitingForInput,
20104            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20105            active_tools: (0..9)
20106                .map(|index| gate4agent_types::ActiveProviderTool {
20107                    id: format!("private-tool-id-{index}"),
20108                    name: if index == 0 {
20109                        format!("  Read\u{0000}{}", "x".repeat(80))
20110                    } else {
20111                        format!("Tool-{index}")
20112                    },
20113                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20114                })
20115                .collect(),
20116            interactions: vec![gate4agent_types::ProviderInteraction {
20117                id: gate4agent_types::ProviderInteractionId(17),
20118                source: source.clone(),
20119                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20120                interaction_kind: ProviderInteractionKind::Approval,
20121                tool_name: " Approve\u{0007}Tool ".to_owned(),
20122                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20123                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20124                resume_lead_activity: Some(ProviderActivity::Working),
20125                status: ProviderInteractionStatus::Pending,
20126            }],
20127            subagents: vec![gate4agent_types::ProviderSubagent {
20128                source: source.clone(),
20129                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20130                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20131                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20132            }],
20133            sources: vec![gate4agent_types::ProviderSourceCursor {
20134                source,
20135                sequence: 17,
20136                gap_count: 2,
20137                stale: true,
20138            }],
20139            last_event: Some(ProviderEvent::Error {
20140                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20141            }),
20142            gap_count: 2,
20143            stale: true,
20144        };
20145        let address = terminal_address(3);
20146        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20147        assert_eq!(entry.address, address);
20148        assert_eq!(entry.progress.provider_sequence, 17);
20149        assert_eq!(entry.progress.active_tool_count, 9);
20150        assert_eq!(
20151            entry.progress.active_tool_labels.len(),
20152            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20153        );
20154        assert_eq!(entry.progress.subagent_count, 1);
20155        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20156        assert!(entry.progress.truncated);
20157        let encoded = serde_json::to_string(&entry).unwrap();
20158        for forbidden in [
20159            "PRIVATE_PROVIDER_SESSION",
20160            "PRIVATE_PROVIDER_PATH",
20161            "PRIVATE_MODEL",
20162            "PRIVATE_TOOL_INVENTORY",
20163            "PRIVATE_CURRENT_PROMPT",
20164            "PRIVATE_TOOL_INPUT_JSON",
20165            "PRIVATE_PROVIDER_REQUEST",
20166            "PRIVATE_INTERACTION_PROMPT",
20167            "PRIVATE_INTERACTION_AGENT",
20168            "PRIVATE_SUBAGENT_ID",
20169            "PRIVATE_SUBAGENT_TYPE",
20170            "PRIVATE_SUBAGENT_DESCRIPTION",
20171            "PRIVATE_PROVIDER_ERROR",
20172            "input_json",
20173            "current_prompt",
20174            "session_id",
20175        ] {
20176            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20177        }
20178
20179        let snapshot = NodeSnapshot {
20180            node_id: NodeId::new("node-progress-test").unwrap(),
20181            enabled_providers: vec![agent("claude")],
20182            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20183            workspaces: Vec::new(),
20184            session_records: Vec::new(),
20185            managed_worktrees: Vec::new(),
20186            launch_inventory: None,
20187            agent_progress: vec![entry],
20188        };
20189        let mut legacy_reply = ResponseEnvelope {
20190            request_id: 1,
20191            result: Ok(NodeResponse::Snapshot {
20192                event_sequence: 0,
20193                controller: None,
20194                snapshot: snapshot.clone(),
20195            }),
20196        };
20197        project_response_without_agent_progress(&mut legacy_reply);
20198        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20199            panic!("agent progress projection changed response kind");
20200        };
20201        assert!(legacy.agent_progress.is_empty());
20202        assert_eq!(snapshot.agent_progress.len(), 1);
20203    }
20204
20205    fn terminal_address(generation: u64) -> SessionAddress {
20206        SessionAddress {
20207            workspace_id: WorkspaceId::new("primary").unwrap(),
20208            session: SessionKey {
20209                instance_id: AgentInstanceId(41),
20210                generation: SessionGeneration(generation),
20211            },
20212        }
20213    }
20214
20215    fn spawn_spec_fixture() -> SpawnSpec {
20216        SpawnSpec {
20217            target: crate::protocol::SpawnTarget {
20218                node_id: NodeId::new("node-terminal-test").unwrap(),
20219                workspace_id: WorkspaceId::new("primary").unwrap(),
20220                worktree_id: None,
20221            },
20222            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20223            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20224                .unwrap(),
20225            overrides: crate::protocol::SpawnOverrides::default(),
20226            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20227            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20228            required_capabilities: SpawnRequiredCapabilities::default(),
20229        }
20230    }
20231
20232    fn environment_profile_test_shared(
20233    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20234        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20235        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20236            "local-claude-r1",
20237        )
20238        .unwrap();
20239        let spawn_profiles = SpawnProfileRegistry::new([
20240            crate::protocol::SpawnProfileDefaults {
20241                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20242                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20243                provider: agent("claude"),
20244                mode: SessionMode::Pty,
20245                terminal_size: gate4agent_types::TerminalSize {
20246                    rows: 24,
20247                    columns: 80,
20248                },
20249                prompt: None,
20250                bundle_id: None,
20251                context_id: None,
20252                environment_profile_id: Some(profile_id.clone()),
20253            },
20254        ])
20255        .unwrap();
20256        let catalog = active_registry().unwrap();
20257        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20258        let control = runtime.native_launch_profile_control();
20259        let node_profile = NodeEnvironmentProfile::new(
20260            profile_id.clone(),
20261            profile_revision.clone(),
20262            agent("claude"),
20263            [NativeLaunchProfile::new(
20264                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20265                agent("claude"),
20266                TransportKind::Pty,
20267                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20268                Arc::new(EmptyEnvironmentResolver),
20269            )
20270            .unwrap()],
20271        )
20272        .unwrap();
20273        let (binding, native_profiles, _materialization) = node_profile.into_parts();
20274        for native_profile in native_profiles {
20275            runtime.upsert_native_launch_profile(native_profile).unwrap();
20276        }
20277        let workspace = WorkspaceConfig::new(
20278            WorkspaceId::new("primary").unwrap(),
20279            std::env::current_dir().unwrap(),
20280        )
20281        .unwrap();
20282        let shared = NodeShared::new_with_incarnation(
20283            handle,
20284            "fixture-token".to_owned(),
20285            NodeId::new("node-terminal-test").unwrap(),
20286            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20287            vec![workspace],
20288            vec![agent("claude")],
20289            ProviderRuntimeStatuses::default(),
20290            None,
20291            Vec::new(),
20292            Vec::new(),
20293            spawn_profiles,
20294            Some(control),
20295            None,
20296            None,
20297            None,
20298            Vec::new(),
20299            Vec::new(),
20300            Vec::new(),
20301            Vec::new(),
20302            None,
20303            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20304        );
20305        shared
20306            .environment_profiles
20307            .write()
20308            .unwrap_or_else(|poisoned| poisoned.into_inner())
20309            .insert(binding.id.clone(), binding);
20310        (
20311            shared,
20312            runtime,
20313            ResolvedEnvironmentProfileReceipt {
20314                profile_id,
20315                profile_revision,
20316                network_allowlist: None,
20317                browser_profile_id: None,
20318            },
20319        )
20320    }
20321
20322    fn materialization_test_shared(
20323        deny_secret: bool,
20324    ) -> (
20325        NodeShared,
20326        NativeRuntime,
20327        ResolvedEnvironmentProfileReceipt,
20328        PathBuf,
20329        Arc<AtomicBool>,
20330    ) {
20331        let workspace = WorkspaceConfig::new(
20332            WorkspaceId::new("primary").unwrap(),
20333            std::env::current_dir().unwrap(),
20334        )
20335        .unwrap();
20336        materialization_test_shared_with_workspace(deny_secret, workspace)
20337    }
20338
20339    fn materialization_test_shared_with_workspace(
20340        deny_secret: bool,
20341        workspace: WorkspaceConfig,
20342    ) -> (
20343        NodeShared,
20344        NativeRuntime,
20345        ResolvedEnvironmentProfileReceipt,
20346        PathBuf,
20347        Arc<AtomicBool>,
20348    ) {
20349        let root = temporary_workspace_root(if deny_secret {
20350            "materialization-denied"
20351        } else {
20352            "materialization-ready"
20353        });
20354        std::fs::create_dir_all(&root).unwrap();
20355        let materialization_root = root.join("session-environments");
20356        let deny = Arc::new(AtomicBool::new(deny_secret));
20357        let resolver = Arc::new(FixtureSecretResolver {
20358            deny: Arc::clone(&deny),
20359        });
20360        let materializer = SessionEnvironmentMaterializer::new(
20361            materialization_root,
20362            resolver,
20363        )
20364        .unwrap();
20365        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20366        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20367            "materialized-claude-r1",
20368        )
20369        .unwrap();
20370        let materialization = NodeSessionMaterializationProfile::new(
20371            vec![NodeSessionEnvironmentMutation::SetSecret {
20372                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20373                reference: NodeSecretReference::new("fixture-token").unwrap(),
20374            }],
20375            vec![NodeSessionPathBinding::new(
20376                "GATE4AGENT_PROVIDER_HOME",
20377                NodeSessionPathClass::ProviderHome,
20378            )
20379            .unwrap()],
20380            vec![NodeSessionFile::secret(
20381                NodeSessionPathClass::Config,
20382                "auth/token",
20383                NodeSecretReference::new("fixture-token").unwrap(),
20384            )
20385            .unwrap()],
20386        )
20387        .unwrap();
20388        let catalog = active_registry().unwrap();
20389        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20390        let control = runtime.native_launch_profile_control();
20391        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20392            profile_id.clone(),
20393            profile_revision.clone(),
20394            agent("claude"),
20395            [NativeLaunchProfile::new(
20396                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20397                agent("claude"),
20398                TransportKind::Pty,
20399                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20400                Arc::new(EmptyEnvironmentResolver),
20401            )
20402            .unwrap()],
20403            Some(materialization),
20404        )
20405        .unwrap();
20406        let (binding, native_profiles, materialization) = node_profile.into_parts();
20407        for native_profile in native_profiles {
20408            runtime.upsert_native_launch_profile(native_profile).unwrap();
20409        }
20410        let shared = NodeShared::new_with_incarnation(
20411            handle,
20412            "fixture-token".to_owned(),
20413            NodeId::new("node-materialization-test").unwrap(),
20414            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20415            vec![workspace],
20416            vec![agent("claude")],
20417            ProviderRuntimeStatuses::default(),
20418            None,
20419            Vec::new(),
20420            Vec::new(),
20421            SpawnProfileRegistry::default(),
20422            Some(control),
20423            None,
20424            Some(materializer),
20425            None,
20426            Vec::new(),
20427            Vec::new(),
20428            Vec::new(),
20429            Vec::new(),
20430            None,
20431            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20432        );
20433        shared
20434            .environment_profiles
20435            .write()
20436            .unwrap_or_else(|poisoned| poisoned.into_inner())
20437            .insert(binding.id.clone(), binding);
20438        shared
20439            .environment_materialization_profiles
20440            .write()
20441            .unwrap_or_else(|poisoned| poisoned.into_inner())
20442            .insert(profile_id.clone(), materialization.unwrap());
20443        (
20444            shared,
20445            runtime,
20446            ResolvedEnvironmentProfileReceipt {
20447                profile_id,
20448                profile_revision,
20449                network_allowlist: None,
20450                browser_profile_id: None,
20451            },
20452            root,
20453            deny,
20454        )
20455    }
20456
20457    #[cfg(feature = "fixture")]
20458    #[derive(Clone, Copy)]
20459    enum CodexHomeProfile {
20460        Missing,
20461        WrongClass,
20462        Exact,
20463    }
20464
20465    #[cfg(feature = "fixture")]
20466    fn codex_bundle_test_shared(
20467        home_profile: CodexHomeProfile,
20468    ) -> (
20469        NodeShared,
20470        NativeRuntime,
20471        ResolvedEnvironmentProfileReceipt,
20472        NodeBundle,
20473        PathBuf,
20474    ) {
20475        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20476        const CLAUDE_MANIFEST: &[u8] =
20477            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20478        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20479        const DIGEST: &str =
20480            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20481
20482        let (shared, mut runtime, _claude_receipt, root, _deny) =
20483            materialization_test_shared(false);
20484        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20485            CodexHomeProfile::Missing => "codex-home-missing",
20486            CodexHomeProfile::WrongClass => "codex-home-wrong",
20487            CodexHomeProfile::Exact => "codex-home-exact",
20488        })
20489        .unwrap();
20490        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20491            "codex-home-r1",
20492        )
20493        .unwrap();
20494        let materialization = match home_profile {
20495            CodexHomeProfile::Missing => None,
20496            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20497                Some(NodeSessionMaterializationProfile::new(
20498                    Vec::new(),
20499                    vec![NodeSessionPathBinding::new(
20500                        "CODEX_HOME",
20501                        if matches!(home_profile, CodexHomeProfile::Exact) {
20502                            NodeSessionPathClass::ProviderHome
20503                        } else {
20504                            NodeSessionPathClass::Config
20505                        },
20506                    )
20507                    .unwrap()],
20508                    Vec::new(),
20509                )
20510                .unwrap())
20511            }
20512        };
20513        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20514            profile_id.clone(),
20515            profile_revision.clone(),
20516            agent("codex"),
20517            [NativeLaunchProfile::new(
20518                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20519                agent("codex"),
20520                TransportKind::Pty,
20521                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20522                Arc::new(EmptyEnvironmentResolver),
20523            )
20524            .unwrap()],
20525            materialization,
20526        )
20527        .unwrap();
20528        let (binding, native_profiles, materialization) = node_profile.into_parts();
20529        for native_profile in native_profiles {
20530            runtime.upsert_native_launch_profile(native_profile).unwrap();
20531        }
20532        shared
20533            .environment_profiles
20534            .write()
20535            .unwrap_or_else(|poisoned| poisoned.into_inner())
20536            .insert(binding.id.clone(), binding);
20537        if let Some(materialization) = materialization {
20538            shared
20539                .environment_materialization_profiles
20540                .write()
20541                .unwrap_or_else(|poisoned| poisoned.into_inner())
20542                .insert(profile_id.clone(), materialization);
20543        }
20544
20545        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20546        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20547        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20548        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20549        std::fs::write(
20550            bundle_source.join(".claude-plugin/plugin.json"),
20551            CLAUDE_MANIFEST,
20552        )
20553        .unwrap();
20554        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20555        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20556        let bundle = NodeBundle::new(
20557            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20558            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20559            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20560            &bundle_source,
20561        )
20562        .unwrap();
20563        *shared
20564            .bundle_catalog
20565            .write()
20566            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20567            BundleCatalog::new([bundle.clone()]).unwrap();
20568        (
20569            shared,
20570            runtime,
20571            ResolvedEnvironmentProfileReceipt {
20572                profile_id,
20573                profile_revision,
20574                network_allowlist: None,
20575                browser_profile_id: None,
20576            },
20577            bundle,
20578            root,
20579        )
20580    }
20581
20582    #[cfg(feature = "fixture")]
20583    #[test]
20584    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20585        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20586            let (shared, runtime, receipt, bundle, root) =
20587                codex_bundle_test_shared(home_profile);
20588            let error = shared
20589                .bundle_layout(
20590                    &agent("codex"),
20591                    SessionMode::Pty,
20592                    Some(&receipt),
20593                    &bundle,
20594                )
20595                .unwrap_err();
20596            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20597            assert!(shared
20598                .materializations
20599                .lock()
20600                .unwrap_or_else(|poisoned| poisoned.into_inner())
20601                .is_empty());
20602            let materialization_root = root.join("session-environments");
20603            assert!(!std::fs::read_dir(&materialization_root)
20604                .unwrap()
20605                .filter_map(Result::ok)
20606                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20607            drop(shared);
20608            drop(runtime);
20609            let _ = std::fs::remove_dir_all(root);
20610        }
20611    }
20612
20613    #[cfg(feature = "fixture")]
20614    #[test]
20615    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20616        let (shared, runtime, receipt, bundle, root) =
20617            codex_bundle_test_shared(CodexHomeProfile::Exact);
20618        let address = SessionAddress {
20619            workspace_id: WorkspaceId::new("primary").unwrap(),
20620            session: SessionKey {
20621                instance_id: AgentInstanceId(310),
20622                generation: SessionGeneration(1),
20623            },
20624        };
20625        let (overlay, guard) = shared
20626            .prepare_session_materialization(
20627                &address,
20628                &agent("codex"),
20629                SessionMode::Pty,
20630                Some(&receipt),
20631                Some(&bundle.receipt()),
20632                None,
20633                None,
20634            )
20635            .unwrap()
20636            .unwrap();
20637        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20638        let ownership = shared
20639            .materializations
20640            .lock()
20641            .unwrap_or_else(|poisoned| poisoned.into_inner())
20642            .get(guard.id().unwrap())
20643            .unwrap()
20644            .clone();
20645        let (profile, _, _) = shared
20646            .materialization_profile(
20647                &agent("codex"),
20648                SessionMode::Pty,
20649                Some(&receipt),
20650                Some(&bundle.receipt()),
20651                None,
20652            )
20653            .unwrap()
20654            .unwrap();
20655        let environment = shared
20656            .session_environment_materializer
20657            .as_ref()
20658            .unwrap()
20659            .resolve_environment(&ownership, &profile)
20660            .unwrap();
20661        assert!(environment.iter().any(|mutation| {
20662            mutation.key == OsString::from("CODEX_HOME")
20663                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20664        }));
20665        assert_eq!(
20666            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20667                .unwrap(),
20668            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20669        );
20670        assert!(std::fs::read_dir(ownership.bundle_root())
20671            .unwrap()
20672            .next()
20673            .is_none());
20674        drop(guard);
20675        drop(shared);
20676        drop(runtime);
20677        let _ = std::fs::remove_dir_all(root);
20678    }
20679
20680    #[cfg(feature = "fixture")]
20681    #[test]
20682    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20683        let (shared, runtime, receipt, bundle, root) =
20684            codex_bundle_test_shared(CodexHomeProfile::Exact);
20685        let bundle_receipt = bundle.receipt();
20686        let address = SessionAddress {
20687            workspace_id: WorkspaceId::new("primary").unwrap(),
20688            session: SessionKey {
20689                instance_id: AgentInstanceId(311),
20690                generation: SessionGeneration(1),
20691            },
20692        };
20693        let (_overlay, guard) = shared
20694            .prepare_session_materialization(
20695                &address,
20696                &agent("codex"),
20697                SessionMode::Pty,
20698                Some(&receipt),
20699                Some(&bundle_receipt),
20700                None,
20701                None,
20702            )
20703            .unwrap()
20704            .unwrap();
20705        let materialization_id = guard.id().unwrap().clone();
20706        let skill_path = shared
20707            .materializations
20708            .lock()
20709            .unwrap_or_else(|poisoned| poisoned.into_inner())
20710            .get(&materialization_id)
20711            .unwrap()
20712            .provider_home()
20713            .join("skills/review-code/SKILL.md");
20714        let record_id = shared
20715            .bind_spawn_session_with_materialization(
20716                &address,
20717                agent("codex"),
20718                SessionMode::Pty,
20719                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20720                SpawnRecordPolicy::ProviderIdentityOnly,
20721                Some(receipt.clone()),
20722                Some(bundle_receipt.clone()),
20723                None,
20724                Some(materialization_id.clone()),
20725            )
20726            .unwrap()
20727            .unwrap();
20728        guard.retain();
20729        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20730
20731        let error = match shared.resolve_record_materialization(
20732                &record_id,
20733                &agent("codex"),
20734                SessionMode::Pty,
20735                Some(&receipt),
20736                Some(&bundle_receipt),
20737                None,
20738            ) {
20739                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20740                Err(error) => error,
20741            };
20742        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20743        assert_eq!(
20744            shared
20745                .materializations
20746                .lock()
20747                .unwrap_or_else(|poisoned| poisoned.into_inner())
20748                .get(&materialization_id)
20749                .unwrap()
20750                .state(),
20751            MaterializationState::RecoveryRequired,
20752        );
20753        drop(shared);
20754        drop(runtime);
20755        let _ = std::fs::remove_dir_all(root);
20756    }
20757
20758    fn bind_record_owned_materialization_fixture(
20759        shared: &NodeShared,
20760        receipt: &ResolvedEnvironmentProfileReceipt,
20761        instance_id: u64,
20762    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20763        let address = SessionAddress {
20764            workspace_id: WorkspaceId::new("primary").unwrap(),
20765            session: SessionKey {
20766                instance_id: AgentInstanceId(instance_id),
20767                generation: SessionGeneration(1),
20768            },
20769        };
20770        let (overlay, materialization_guard) = shared
20771            .prepare_session_materialization(
20772                &address,
20773                &agent("claude"),
20774                SessionMode::Pty,
20775                Some(receipt),
20776                None,
20777                None,
20778                None,
20779            )
20780            .unwrap()
20781            .unwrap();
20782        let materialization_id = materialization_guard.id().unwrap().clone();
20783        let materialization_root = shared
20784            .materializations
20785            .lock()
20786            .unwrap_or_else(|poisoned| poisoned.into_inner())
20787            .get(&materialization_id)
20788            .unwrap()
20789            .root()
20790            .to_path_buf();
20791        let selection = shared
20792            .select_environment_profile(
20793                address.session.instance_id,
20794                &agent("claude"),
20795                SessionMode::Pty,
20796                Some(receipt),
20797            )
20798            .unwrap()
20799            .unwrap();
20800        assert!(shared
20801            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20802            .unwrap()
20803            .is_none());
20804        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20805        let record_id = shared
20806            .bind_spawn_session_with_materialization(
20807                &address,
20808                agent("claude"),
20809                SessionMode::Pty,
20810                policy,
20811                SpawnRecordPolicy::ProviderIdentityOnly,
20812                Some(receipt.clone()),
20813                None,
20814                None,
20815                Some(materialization_id.clone()),
20816            )
20817            .unwrap()
20818            .unwrap();
20819        selection.retain();
20820        materialization_guard.retain();
20821        (address, record_id, materialization_id, materialization_root)
20822    }
20823
20824    #[test]
20825    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20826        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20827        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20828            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20829        }));
20830        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20831        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20832        let mut spec = spawn_spec_fixture();
20833        spec.expected_profile_revision =
20834            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20835        assert!(matches!(
20836            &spec.overrides.environment_profile_id,
20837            crate::protocol::SpawnOverride::Inherit,
20838        ));
20839        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20840            .requires_child_environment_profile_capability());
20841        assert!(request_requires_child_environment_profile(
20842            &shared,
20843            &NodeRequest::SpawnSpec { spec: spec.clone() },
20844        ));
20845
20846        let address = terminal_address(1);
20847        shared.bind_session_with_policy(
20848            &address,
20849            ProviderRuntimePolicy::raw_pty(),
20850            Some(environment_profile.clone()),
20851        );
20852        assert!(request_requires_child_environment_profile(
20853            &shared,
20854            &NodeRequest::Input {
20855                session: address.clone(),
20856                text: "x".to_owned(),
20857            },
20858        ));
20859
20860        let mut record = record("claude", "profile-record");
20861        record.active_session = Some(address.clone());
20862        record.environment_profile = Some(environment_profile.clone());
20863        let record_id = record.record_id.clone();
20864        shared.insert_record(record.clone()).unwrap();
20865        assert!(request_requires_child_environment_profile(
20866            &shared,
20867            &NodeRequest::ResumeSessionRecord {
20868                record_id,
20869                terminal_size: gate4agent_types::TerminalSize {
20870                    rows: 24,
20871                    columns: 80,
20872                },
20873                initial_prompt: None,
20874            },
20875        ));
20876
20877        let snapshot = snapshot_for_wire(
20878            &shared, false, true, true, true, false, false, false, false, true,
20879        );
20880        assert_eq!(snapshot.session_records.len(), 1);
20881        assert!(snapshot.session_records[0].environment_profile.is_none());
20882        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20883            sequence: 1,
20884            event: NodeEvent::SessionRecordUpserted { record },
20885        });
20886        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20887            panic!("record projection changed the event kind");
20888        };
20889        assert!(record.environment_profile.is_none());
20890
20891        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20892        let mut reply = ResponseEnvelope {
20893            request_id: 1,
20894            result: Ok(NodeResponse::SpawnSpecAccepted {
20895                receipt: resolved.receipt_with_environment(
20896                    shared.incarnation_id,
20897                    address,
20898                    Some(environment_profile),
20899                ),
20900            }),
20901        };
20902        project_response_without_child_environment_profile(&mut reply);
20903        assert_eq!(
20904            reply.result.unwrap_err().code,
20905            NodeFailureCode::UnsupportedCapability,
20906        );
20907    }
20908
20909    #[test]
20910    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20911        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20912        let snapshot = shared.snapshot();
20913        let profiles = snapshot
20914            .launch_inventory
20915            .as_ref()
20916            .and_then(|inventory| inventory.spawn_profiles.as_ref())
20917            .unwrap();
20918        assert_eq!(profiles.len(), 1);
20919        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20920        assert!(snapshot.requires_child_environment_profile_capability());
20921
20922        let mut legacy = snapshot.clone();
20923        clear_snapshot_child_environment_profiles(&mut legacy);
20924        assert!(legacy
20925            .launch_inventory
20926            .as_ref()
20927            .unwrap()
20928            .spawn_profiles
20929            .as_ref()
20930            .unwrap()[0]
20931            .environment_profile
20932            .is_none());
20933        assert!(!legacy.requires_child_environment_profile_capability());
20934
20935        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20936        let bindings = shared
20937            .environment_profiles
20938            .read()
20939            .unwrap_or_else(|poisoned| poisoned.into_inner())
20940            .clone();
20941        let mut provider_mismatch = bindings.clone();
20942        provider_mismatch
20943            .values_mut()
20944            .next()
20945            .unwrap()
20946            .provider = agent("codex");
20947        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20948        let mut mode_mismatch = profile;
20949        mode_mismatch.mode = SessionMode::Inline;
20950        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20951
20952        shared
20953            .environment_profiles
20954            .write()
20955            .unwrap_or_else(|poisoned| poisoned.into_inner())
20956            .clear();
20957        assert!(shared
20958            .snapshot()
20959            .launch_inventory
20960            .unwrap()
20961            .spawn_profiles
20962            .unwrap()
20963            .is_empty());
20964    }
20965
20966    #[test]
20967    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20968        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20969            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20970        }));
20971        let shared = terminal_test_shared();
20972        let bundle = ResolvedBundleReceipt {
20973            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20974            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20975            digest: crate::protocol::SpawnBundleDigest::new(format!(
20976                "sha256:{}",
20977                "0".repeat(64),
20978            ))
20979            .unwrap(),
20980        };
20981        let mut bundled = record("claude", "bundle-record");
20982        bundled.bundle = Some(bundle.clone());
20983        let record_id = bundled.record_id.clone();
20984        shared.insert_record(bundled.clone()).unwrap();
20985        assert!(request_requires_session_bundle(
20986            &shared,
20987            &NodeRequest::ForgetSessionRecord {
20988                record_id: record_id.clone(),
20989            },
20990        ));
20991
20992        let snapshot = snapshot_for_wire(
20993            &shared, false, true, true, true, true, false, false, false, true,
20994        );
20995        assert!(snapshot.session_records[0].bundle.is_none());
20996        let launch = snapshot.launch_inventory.as_ref().unwrap();
20997        assert!(launch.spawn_profiles.is_some());
20998        assert!(launch.bundles.is_none());
20999        let event = project_event_without_session_bundle(NodeEventEnvelope {
21000            sequence: 1,
21001            event: NodeEvent::SessionRecordUpserted { record: bundled },
21002        });
21003        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21004            panic!("record projection changed the event kind");
21005        };
21006        assert!(record.bundle.is_none());
21007
21008        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21009        let mut reply = ResponseEnvelope {
21010            request_id: 1,
21011            result: Ok(NodeResponse::SpawnSpecAccepted {
21012                receipt: resolved.receipt_with_materialization(
21013                    shared.incarnation_id,
21014                    terminal_address(17),
21015                    None,
21016                    Some(bundle),
21017                    None,
21018                ),
21019            }),
21020        };
21021        project_response_without_session_bundle(&mut reply);
21022        assert_eq!(
21023            reply.result.unwrap_err().code,
21024            NodeFailureCode::UnsupportedCapability,
21025        );
21026    }
21027
21028    #[test]
21029    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21030        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21031            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21032        }));
21033        let context_id = SpawnContextId::new("context-review").unwrap();
21034        let context = ResolvedContextPackReceipt {
21035            id: context_id.clone(),
21036            digest: crate::protocol::SpawnContextDigest::new(format!(
21037                "sha256:{}",
21038                "c".repeat(64),
21039            ))
21040            .unwrap(),
21041            lineage: ContextPackLineageReceipt {
21042                source_node_id: NodeId::new("node-source").unwrap(),
21043                source_session: terminal_address(1),
21044                source_provider: agent("kimi"),
21045            },
21046            source_message_count: 2,
21047            retained_message_count: 2,
21048            byte_len: 256,
21049            truncated: false,
21050        };
21051        assert!(context.is_valid());
21052        let mut bound_record = record("codex", "context-record");
21053        bound_record.context_id = Some(context_id);
21054        bound_record.context = Some(context.clone());
21055        let session = gate4agent_types::SessionSnapshot {
21056            instance_id: AgentInstanceId(91),
21057            agent_id: agent("kimi"),
21058            transport: TransportKind::Pty,
21059            generation: SessionGeneration(3),
21060            status: SessionStatus::Running,
21061            pending_operation: None,
21062            pending_input: None,
21063            process_id: Some(9001),
21064            terminal_size: None,
21065            terminal_frame: None,
21066            terminal_stale: None,
21067            session_options: None,
21068            capabilities: gate4agent_types::CapabilitySnapshot::default(),
21069            history: gate4agent_types::HistorySnapshot {
21070                pending: None,
21071                candidates: vec![HistoryCandidateSummary {
21072                    id: "candidate-opaque".to_owned(),
21073                    session_id_hint: "session-hint".to_owned(),
21074                    modified_at_unix_ms: Some(1),
21075                }],
21076                loaded_candidate_id: Some("candidate-opaque".to_owned()),
21077                loaded: Some(HistorySessionRecord {
21078                    session_id: "private-vendor-session".to_owned(),
21079                    title: Some("private title".to_owned()),
21080                    cwd: Some(r"C:\private\history-root".to_owned()),
21081                    model: Some("private-model".to_owned()),
21082                    message_count: 2,
21083                    completed_turn_count: None,
21084                    total_tokens: 19,
21085                    messages: vec![
21086                        gate4agent_types::HistoryMessageRecord {
21087                            role: gate4agent_types::HistoryMessageRole::User,
21088                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21089                        },
21090                        gate4agent_types::HistoryMessageRecord {
21091                            role: gate4agent_types::HistoryMessageRole::Assistant,
21092                            text: "private answer".to_owned(),
21093                        },
21094                    ],
21095                }),
21096                last_error: None,
21097            },
21098            resume: gate4agent_types::ResumeSnapshot::default(),
21099            foreground: gate4agent_types::ForegroundSnapshot::default(),
21100            provider: gate4agent_types::ProviderSnapshot::default(),
21101            screen_state: gate4agent_types::PtyScreenState::default(),
21102        };
21103        let original = NodeSnapshot {
21104            node_id: NodeId::new("node-terminal-test").unwrap(),
21105            enabled_providers: vec![agent("kimi"), agent("codex")],
21106            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21107            workspaces: vec![WorkspaceSnapshot {
21108                workspace_id: WorkspaceId::new("primary").unwrap(),
21109                canonical_root: opaque_windows_path(
21110                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21111                ),
21112                sessions: vec![session],
21113                worktree_service_mode: None,
21114                managed_worktree_profiles: None,
21115            }],
21116            session_records: vec![bound_record],
21117            managed_worktrees: Vec::new(),
21118            launch_inventory: None,
21119            agent_progress: Vec::new(),
21120        };
21121
21122        let mut negotiated = original.clone();
21123        project_snapshot_history_for_wire(&mut negotiated, true);
21124        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21125        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21126        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21127        assert!(!serde_json::to_string(&negotiated)
21128            .unwrap()
21129            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21130
21131        let mut legacy = original;
21132        project_snapshot_history_for_wire(&mut legacy, false);
21133        clear_snapshot_context_packs(&mut legacy);
21134        assert_eq!(
21135            legacy.workspaces[0].sessions[0].history,
21136            gate4agent_types::HistorySnapshot::default(),
21137        );
21138        assert!(legacy.session_records[0].context_id.is_none());
21139        assert!(legacy.session_records[0].context.is_none());
21140        let encoded = serde_json::to_string(&legacy).unwrap();
21141        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21142        assert!(!encoded.contains("context-review"));
21143
21144        let mut reply = ResponseEnvelope {
21145            request_id: 7,
21146            result: Ok(NodeResponse::ContextPackExported { context }),
21147        };
21148        project_response_without_context_pack(&mut reply);
21149        assert_eq!(
21150            reply.result.unwrap_err().code,
21151            NodeFailureCode::UnsupportedCapability,
21152        );
21153    }
21154
21155    #[test]
21156    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21157        let shared = terminal_test_shared();
21158        let history = HistorySessionRecord {
21159            session_id: "fixture-history".to_owned(),
21160            title: None,
21161            cwd: None,
21162            model: None,
21163            message_count: 1,
21164            completed_turn_count: None,
21165            total_tokens: 0,
21166            messages: vec![gate4agent_types::HistoryMessageRecord {
21167                role: gate4agent_types::HistoryMessageRole::User,
21168                text: "bounded handoff".to_owned(),
21169            }],
21170        };
21171        let pack = NodeContextPack::export(
21172            ContextPackLineageReceipt {
21173                source_node_id: shared.node_id.clone(),
21174                source_session: terminal_address(1),
21175                source_provider: agent("claude"),
21176            },
21177            &history,
21178        )
21179        .unwrap();
21180        let context = shared
21181            .context_catalog
21182            .write()
21183            .unwrap_or_else(|poisoned| poisoned.into_inner())
21184            .insert(pack)
21185            .unwrap();
21186        let mut retained = record("codex", "context-retained-record");
21187        retained.context_id = Some(context.id.clone());
21188        retained.context = Some(context.clone());
21189        let record_id = retained.record_id.clone();
21190        shared.insert_record(retained).unwrap();
21191        assert_eq!(
21192            shared.forget_context_pack(&context.id).unwrap_err().code,
21193            NodeFailureCode::ContextPackBusy,
21194        );
21195        assert!(shared
21196            .context_catalog
21197            .read()
21198            .unwrap_or_else(|poisoned| poisoned.into_inner())
21199            .get(&context.id)
21200            .is_some());
21201        shared
21202            .session_records
21203            .lock()
21204            .unwrap_or_else(|poisoned| poisoned.into_inner())
21205            .records
21206            .remove(&record_id);
21207
21208        let mut spec = spawn_spec_fixture();
21209        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21210            value: context.id.clone(),
21211        };
21212        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21213        let receipt = resolved.receipt_with_materialization(
21214            shared.incarnation_id,
21215            terminal_address(2),
21216            None,
21217            None,
21218            Some(context.clone()),
21219        );
21220        assert!(receipt.context_binding_is_valid());
21221        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21222
21223        shared.forget_context_pack(&context.id).unwrap();
21224        assert!(shared
21225            .context_catalog
21226            .read()
21227            .unwrap_or_else(|poisoned| poisoned.into_inner())
21228            .get(&context.id)
21229            .is_none());
21230        assert!(shared
21231            .replay_spawn_spec(&spec, Instant::now())
21232            .unwrap()
21233            .is_none());
21234    }
21235
21236    #[test]
21237    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21238        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21239        let address = terminal_address(1);
21240        let selection = shared
21241            .select_environment_profile(
21242                address.session.instance_id,
21243                &agent("claude"),
21244                SessionMode::Pty,
21245                Some(&environment_profile),
21246            )
21247            .unwrap()
21248            .unwrap();
21249        shared.bind_session_with_policy(
21250            &address,
21251            ProviderRuntimePolicy::raw_pty(),
21252            Some(environment_profile.clone()),
21253        );
21254        selection.retain();
21255        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21256        assert!(matches!(
21257            runtime.remove_native_launch_profile(&native_id),
21258            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21259        ));
21260        assert!(shared.remove_binding(&address).is_some());
21261        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21262
21263        let mismatched = ResolvedEnvironmentProfileReceipt {
21264            profile_id: environment_profile.profile_id,
21265            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21266                "local-claude-r2",
21267            )
21268            .unwrap(),
21269            network_allowlist: None,
21270            browser_profile_id: None,
21271        };
21272        let error = match shared.select_environment_profile(
21273                AgentInstanceId(42),
21274                &agent("claude"),
21275                SessionMode::Pty,
21276                Some(&mismatched),
21277            ) {
21278            Ok(_) => panic!("mismatched environment profile revision was selected"),
21279            Err(error) => error,
21280        };
21281        assert_eq!(
21282            error.code,
21283            NodeFailureCode::EnvironmentProfileBindingMismatch,
21284        );
21285        assert!(!shared
21286            .session_bindings
21287            .lock()
21288            .unwrap_or_else(|poisoned| poisoned.into_inner())
21289            .contains_key(&AgentInstanceId(42)));
21290    }
21291
21292    #[test]
21293    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21294        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21295        let address = terminal_address(1);
21296        let selection = shared
21297            .select_environment_profile(
21298                address.session.instance_id,
21299                &agent("claude"),
21300                SessionMode::Pty,
21301                Some(&environment_profile),
21302            )
21303            .unwrap()
21304            .unwrap();
21305        let overlay = NativeInstanceLaunchOverlay::new(
21306            agent("claude"),
21307            TransportKind::Pty,
21308            vec![EnvMutation {
21309                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21310                value: Some(OsString::from("fixture-secret")),
21311            }],
21312            vec![
21313                OsString::from("--plugin-dir"),
21314                std::env::current_dir().unwrap().into_os_string(),
21315            ],
21316        )
21317        .unwrap();
21318        let instance_overlay = shared
21319            .install_prepared_launch_overlay(
21320                address.session.instance_id,
21321                PreparedNativeLaunchOverlay::Instance(overlay),
21322            )
21323            .unwrap()
21324            .unwrap();
21325        let bundle = ResolvedBundleReceipt {
21326            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21327            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21328            digest: crate::protocol::SpawnBundleDigest::new(format!(
21329                "sha256:{}",
21330                "0".repeat(64),
21331            ))
21332            .unwrap(),
21333        };
21334        shared.bind_session_with_materialization(
21335            &address,
21336            ProviderRuntimePolicy::raw_pty(),
21337            Some(environment_profile),
21338            Some(bundle),
21339            None,
21340            None,
21341        );
21342        selection.retain();
21343        instance_overlay.retain();
21344
21345        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21346        assert!(matches!(
21347            runtime.remove_native_launch_profile(&native_id),
21348            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21349        ));
21350        assert!(shared.remove_binding(&address).is_some());
21351        assert!(!shared
21352            .native_launch_profile_control
21353            .as_ref()
21354            .unwrap()
21355            .clear_native_instance_launch_overlay(address.session.instance_id));
21356        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21357    }
21358
21359    #[test]
21360    fn session_environment_materialization_failure_is_pre_child_and_durable() {
21361        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21362        let address = SessionAddress {
21363            workspace_id: WorkspaceId::new("primary").unwrap(),
21364            session: SessionKey {
21365                instance_id: AgentInstanceId(201),
21366                generation: SessionGeneration(1),
21367            },
21368        };
21369        let error = match shared.prepare_session_materialization(
21370            &address,
21371            &agent("claude"),
21372            SessionMode::Pty,
21373            Some(&receipt),
21374            None,
21375            None,
21376            None,
21377        ) {
21378            Ok(_) => panic!("denied local reference unexpectedly materialized"),
21379            Err(error) => error,
21380        };
21381        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21382        assert!(shared.handle.snapshot().sessions.is_empty());
21383        assert!(shared
21384            .session_bindings
21385            .lock()
21386            .unwrap_or_else(|poisoned| poisoned.into_inner())
21387            .is_empty());
21388        assert!(shared
21389            .materializations
21390            .lock()
21391            .unwrap_or_else(|poisoned| poisoned.into_inner())
21392            .is_empty());
21393        drop(shared);
21394        drop(runtime);
21395        std::fs::remove_dir_all(root).unwrap();
21396    }
21397
21398    #[test]
21399    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21400        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21401        let address = SessionAddress {
21402            workspace_id: WorkspaceId::new("primary").unwrap(),
21403            session: SessionKey {
21404                instance_id: AgentInstanceId(202),
21405                generation: SessionGeneration(1),
21406            },
21407        };
21408        let (overlay, materialization_guard) = shared
21409            .prepare_session_materialization(
21410                &address,
21411                &agent("claude"),
21412                SessionMode::Pty,
21413                Some(&receipt),
21414                None,
21415                None,
21416                None,
21417            )
21418            .unwrap()
21419            .unwrap();
21420        let materialization_id = materialization_guard.id().unwrap().clone();
21421        let materialization_root = shared
21422            .materializations
21423            .lock()
21424            .unwrap_or_else(|poisoned| poisoned.into_inner())
21425            .get(&materialization_id)
21426            .unwrap()
21427            .root()
21428            .to_path_buf();
21429        let selection = shared
21430            .select_environment_profile(
21431                address.session.instance_id,
21432                &agent("claude"),
21433                SessionMode::Pty,
21434                Some(&receipt),
21435            )
21436            .unwrap()
21437            .unwrap();
21438        assert!(shared
21439            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21440            .unwrap()
21441            .is_none());
21442        shared.bind_session_with_materialization(
21443            &address,
21444            ProviderRuntimePolicy::raw_pty(),
21445            Some(receipt),
21446            None,
21447            None,
21448            Some(materialization_id),
21449        );
21450        selection.retain();
21451        materialization_guard.retain();
21452
21453        assert!(materialization_root.is_dir());
21454        assert!(shared
21455            .session_bindings
21456            .lock()
21457            .unwrap_or_else(|poisoned| poisoned.into_inner())
21458            .contains_key(&address.session.instance_id));
21459
21460        let removed = shared.remove_binding(&address).unwrap();
21461        shared
21462            .cleanup_session_owned_materialization(&address, &removed)
21463            .unwrap();
21464        assert!(!materialization_root.exists());
21465        assert!(shared
21466            .materializations
21467            .lock()
21468            .unwrap_or_else(|poisoned| poisoned.into_inner())
21469            .is_empty());
21470        drop(shared);
21471        drop(runtime);
21472        std::fs::remove_dir_all(root).unwrap();
21473    }
21474
21475    #[tokio::test]
21476    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21477        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21478        let address = SessionAddress {
21479            workspace_id: WorkspaceId::new("primary").unwrap(),
21480            session: SessionKey {
21481                instance_id: AgentInstanceId(203),
21482                generation: SessionGeneration(1),
21483            },
21484        };
21485        let (overlay, materialization_guard) = shared
21486            .prepare_session_materialization(
21487                &address,
21488                &agent("claude"),
21489                SessionMode::Pty,
21490                Some(&receipt),
21491                None,
21492                None,
21493                None,
21494            )
21495            .unwrap()
21496            .unwrap();
21497        let materialization_id = materialization_guard.id().unwrap().clone();
21498        let materialization_root = shared
21499            .materializations
21500            .lock()
21501            .unwrap_or_else(|poisoned| poisoned.into_inner())
21502            .get(&materialization_id)
21503            .unwrap()
21504            .root()
21505            .to_path_buf();
21506        let selection = shared
21507            .select_environment_profile(
21508                address.session.instance_id,
21509                &agent("claude"),
21510                SessionMode::Pty,
21511                Some(&receipt),
21512            )
21513            .unwrap()
21514            .unwrap();
21515        assert!(shared
21516            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21517            .unwrap()
21518            .is_none());
21519        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21520        let record_id = shared
21521            .bind_spawn_session_with_materialization(
21522                &address,
21523                agent("claude"),
21524                SessionMode::Pty,
21525                policy,
21526                SpawnRecordPolicy::ProviderIdentityOnly,
21527                Some(receipt.clone()),
21528                None,
21529                None,
21530                Some(materialization_id.clone()),
21531            )
21532            .unwrap()
21533            .unwrap();
21534        selection.retain();
21535        materialization_guard.retain();
21536        assert_eq!(
21537            shared
21538                .materializations
21539                .lock()
21540                .unwrap_or_else(|poisoned| poisoned.into_inner())
21541                .get(&materialization_id)
21542                .unwrap()
21543                .owner(),
21544            &MaterializationOwner::Record {
21545                record_id: record_id.clone(),
21546            },
21547        );
21548
21549        let removed = shared.remove_binding(&address).unwrap();
21550        shared
21551            .cleanup_session_owned_materialization(&address, &removed)
21552            .unwrap();
21553        assert!(materialization_root.is_dir());
21554        assert!(shared
21555            .resolve_record_materialization(
21556                &record_id,
21557                &agent("claude"),
21558                SessionMode::Pty,
21559                Some(&receipt),
21560                None,
21561                None,
21562            )
21563            .unwrap()
21564            .is_some());
21565        {
21566            let mut records = shared
21567                .session_records
21568                .lock()
21569                .unwrap_or_else(|poisoned| poisoned.into_inner());
21570            let record = records.records.get_mut(&record_id).unwrap();
21571            record.active_session = None;
21572            record.state = ManagedSessionState::Unavailable;
21573        }
21574        shared.forget_session_record(&record_id).await.unwrap();
21575        assert!(!materialization_root.exists());
21576        assert!(shared
21577            .materializations
21578            .lock()
21579            .unwrap_or_else(|poisoned| poisoned.into_inner())
21580            .is_empty());
21581        drop(shared);
21582        drop(runtime);
21583        std::fs::remove_dir_all(root).unwrap();
21584    }
21585
21586    #[test]
21587    fn record_materialization_revalidation_failure_persists_recovery_required() {
21588        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21589        let state_path = root.join("node-state-v6.json");
21590        shared.state_path = Some(state_path.clone());
21591        let (_address, _record_id, materialization_id, materialization_root) =
21592            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21593        std::fs::write(
21594            materialization_root.join(".gate4agent-materialization-owner"),
21595            b"tampered",
21596        )
21597        .unwrap();
21598
21599        let error = match shared.resolve_record_materialization(
21600            &_record_id,
21601            &agent("claude"),
21602            SessionMode::Pty,
21603            Some(&receipt),
21604            None,
21605            None,
21606        ) {
21607            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21608            Err(error) => error,
21609        };
21610        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21611        assert_eq!(
21612            shared
21613                .materializations
21614                .lock()
21615                .unwrap_or_else(|poisoned| poisoned.into_inner())
21616                .get(&materialization_id)
21617                .unwrap()
21618                .state(),
21619            MaterializationState::RecoveryRequired,
21620        );
21621        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21622        assert_eq!(loaded.materializations.len(), 1);
21623        assert_eq!(
21624            loaded.materializations[0].state(),
21625            MaterializationState::RecoveryRequired,
21626        );
21627        drop(shared);
21628        drop(runtime);
21629        std::fs::remove_dir_all(root).unwrap();
21630    }
21631
21632    #[test]
21633    fn transient_record_materialization_resolution_failure_remains_ready() {
21634        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21635        let (_address, record_id, materialization_id, _materialization_root) =
21636            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21637        deny.store(true, Ordering::Release);
21638
21639        let error = match shared.resolve_record_materialization(
21640            &record_id,
21641            &agent("claude"),
21642            SessionMode::Pty,
21643            Some(&receipt),
21644            None,
21645            None,
21646        ) {
21647            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21648            Err(error) => error,
21649        };
21650        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21651        assert_eq!(
21652            shared
21653                .materializations
21654                .lock()
21655                .unwrap_or_else(|poisoned| poisoned.into_inner())
21656                .get(&materialization_id)
21657                .unwrap()
21658                .state(),
21659            MaterializationState::Ready,
21660        );
21661        drop(shared);
21662        drop(runtime);
21663        std::fs::remove_dir_all(root).unwrap();
21664    }
21665
21666    #[test]
21667    fn startup_missing_record_materialization_is_not_resumable() {
21668        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21669        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21670        shared
21671            .insert_record(ManagedSessionRecord {
21672                record_id: record_id.clone(),
21673                display_name: "missing materialization".to_owned(),
21674                provider: agent("claude"),
21675                mode: SessionMode::Pty,
21676                state: ManagedSessionState::Dormant,
21677                workspace_id: WorkspaceId::new("primary").unwrap(),
21678                canonical_root: opaque_windows_path(
21679                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21680                ),
21681                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21682                    key: gate4agent_types::ProviderSessionKey::SessionId,
21683                    id: "provider-session-missing-materialization".to_owned(),
21684                    transcript_path: None,
21685                }),
21686                active_session: None,
21687                environment_profile: Some(receipt),
21688                bundle: None,
21689                context_id: None,
21690                context: None,
21691                exported_context: None,
21692                task_binding: None,
21693                created_at_unix_ms: 1,
21694                updated_at_unix_ms: 1,
21695                last_error: None,
21696            })
21697            .unwrap();
21698
21699        shared.reconcile_materializations();
21700        let record = shared.record(&record_id).unwrap();
21701        assert_eq!(record.state, ManagedSessionState::Unavailable);
21702        assert!(record.provider_session.is_none());
21703        assert_eq!(
21704            record.last_error.as_deref(),
21705            Some("environment-profile-unavailable"),
21706        );
21707        drop(shared);
21708        drop(runtime);
21709        std::fs::remove_dir_all(root).unwrap();
21710    }
21711
21712    #[tokio::test]
21713    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21714        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21715        let lease = install_managed_lease(&shared);
21716        let address = SessionAddress {
21717            workspace_id: WorkspaceId::new("primary").unwrap(),
21718            session: SessionKey {
21719                instance_id: AgentInstanceId(204),
21720                generation: SessionGeneration(1),
21721            },
21722        };
21723        let (overlay, materialization_guard) = shared
21724            .prepare_session_materialization(
21725                &address,
21726                &agent("claude"),
21727                SessionMode::Pty,
21728                Some(&receipt),
21729                None,
21730                None,
21731                Some(lease.lease_id.clone()),
21732            )
21733            .unwrap()
21734            .unwrap();
21735        let materialization_id = materialization_guard.id().unwrap().clone();
21736        let materialization_root = shared
21737            .materializations
21738            .lock()
21739            .unwrap_or_else(|poisoned| poisoned.into_inner())
21740            .get(&materialization_id)
21741            .unwrap()
21742            .root()
21743            .to_path_buf();
21744        let selection = shared
21745            .select_environment_profile(
21746                address.session.instance_id,
21747                &agent("claude"),
21748                SessionMode::Pty,
21749                Some(&receipt),
21750            )
21751            .unwrap()
21752            .unwrap();
21753        assert!(shared
21754            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21755            .unwrap()
21756            .is_none());
21757        shared.bind_session_with_materialization(
21758            &address,
21759            ProviderRuntimePolicy::raw_pty(),
21760            Some(receipt),
21761            None,
21762            None,
21763            Some(materialization_id),
21764        );
21765        selection.retain();
21766        materialization_guard.retain();
21767
21768        let blocked = shared
21769            .cleanup_managed_worktree(&lease.lease_id, false)
21770            .await
21771            .unwrap_err();
21772        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21773        assert!(materialization_root.is_dir());
21774        let removed = shared.remove_binding(&address).unwrap();
21775        shared
21776            .cleanup_session_owned_materialization(&address, &removed)
21777            .unwrap();
21778        assert!(!materialization_root.exists());
21779        drop(shared);
21780        drop(runtime);
21781        std::fs::remove_dir_all(root).unwrap();
21782    }
21783
21784    #[tokio::test]
21785    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21786        let git_root = temporary_workspace_root("raw-managed-inventory");
21787        let source = git_root.join("source");
21788        let allocation = git_root.join("allocation");
21789        std::fs::create_dir_all(&source).unwrap();
21790        std::fs::create_dir_all(&allocation).unwrap();
21791        run_test_git(&source, &["init"]);
21792        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21793        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21794        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21795        run_test_git(&source, &["add", "seed.txt"]);
21796        run_test_git(&source, &["commit", "-m", "seed"]);
21797        let profile = ManagedWorktreeProfile::new(
21798            WorktreeProfileId::new("default").unwrap(),
21799            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21800            &allocation,
21801            "gate4agent",
21802            "HEAD",
21803            ManagedWorktreeRetention::RemoveWhenReleased,
21804        )
21805        .unwrap();
21806        let workspace = WorkspaceConfig::new(
21807            WorkspaceId::new("primary").unwrap(),
21808            &source,
21809        )
21810        .unwrap()
21811        .with_worktree_service_mode(WorktreeServiceMode::Managed)
21812        .with_managed_worktree_profile(profile.clone())
21813        .unwrap();
21814        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21815            materialization_test_shared_with_workspace(false, workspace);
21816        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21817        let source_text = source.to_string_lossy().into_owned();
21818        let target_text = target.to_string_lossy().into_owned();
21819        let created = create_git_worktree(
21820            &source_text,
21821            &target_text,
21822            "gate4agent/mw-raw-inventory",
21823            Some("HEAD"),
21824        )
21825        .await
21826        .unwrap();
21827        let lease = ManagedWorktreeLeaseRecord {
21828            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21829            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21830            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21831            profile_id: profile.profile_id().clone(),
21832            profile_revision: profile.revision().clone(),
21833            target_root: created.path.clone(),
21834            branch: "gate4agent/mw-raw-inventory".to_owned(),
21835            base_commit: created.head.clone(),
21836            expected_head: Some(created.head.clone()),
21837            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21838            state: ManagedWorktreeLeaseState::Ready,
21839            session_holders: Vec::new(),
21840            record_holders: Vec::new(),
21841            cleanup_failure: None,
21842            created_at_unix_ms: 1,
21843            updated_at_unix_ms: 1,
21844        };
21845        shared
21846            .workspaces
21847            .write()
21848            .unwrap_or_else(|poisoned| poisoned.into_inner())
21849            .insert(lease.workspace_id.clone(), created.path.clone());
21850        *shared
21851            .managed_worktrees
21852            .lock()
21853            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21854            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21855        let address = SessionAddress {
21856            workspace_id: lease.workspace_id.clone(),
21857            session: SessionKey {
21858                instance_id: AgentInstanceId(205),
21859                generation: SessionGeneration(1),
21860            },
21861        };
21862        let (overlay, materialization_guard) = shared
21863            .prepare_session_materialization(
21864                &address,
21865                &agent("claude"),
21866                SessionMode::Pty,
21867                Some(&receipt),
21868                None,
21869                None,
21870                Some(lease.lease_id.clone()),
21871            )
21872            .unwrap()
21873            .unwrap();
21874        let materialization_id = materialization_guard.id().unwrap().clone();
21875        let materialization_root = shared
21876            .materializations
21877            .lock()
21878            .unwrap_or_else(|poisoned| poisoned.into_inner())
21879            .get(&materialization_id)
21880            .unwrap()
21881            .root()
21882            .to_path_buf();
21883        let selection = shared
21884            .select_environment_profile(
21885                address.session.instance_id,
21886                &agent("claude"),
21887                SessionMode::Pty,
21888                Some(&receipt),
21889            )
21890            .unwrap()
21891            .unwrap();
21892        assert!(shared
21893            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21894            .unwrap()
21895            .is_none());
21896        let record_id = shared
21897            .bind_spawn_session_with_materialization(
21898                &address,
21899                agent("claude"),
21900                SessionMode::Pty,
21901                ProviderRuntimePolicy::raw_pty(),
21902                SpawnRecordPolicy::Always,
21903                Some(receipt.clone()),
21904                None,
21905                None,
21906                Some(materialization_id.clone()),
21907            )
21908            .unwrap()
21909            .unwrap();
21910        selection.retain();
21911        materialization_guard.retain();
21912        let record = shared.record(&record_id).unwrap();
21913        assert_eq!(record.state, ManagedSessionState::Live);
21914        assert!(record.provider_session.is_none());
21915        assert!(record.environment_profile.is_none());
21916        assert!(record.bundle.is_none());
21917        assert!(record.context_id.is_none());
21918        assert!(record.context.is_none());
21919        assert_eq!(record.active_session.as_ref(), Some(&address));
21920        assert_eq!(
21921            shared
21922                .materializations
21923                .lock()
21924                .unwrap_or_else(|poisoned| poisoned.into_inner())
21925                .get(&materialization_id)
21926                .unwrap()
21927                .owner(),
21928            &MaterializationOwner::Session {
21929                incarnation_id: shared.incarnation_id,
21930                instance_id: address.session.instance_id,
21931                generation: address.session.generation,
21932            },
21933        );
21934        {
21935            let mut bindings = shared
21936                .session_bindings
21937                .lock()
21938                .unwrap_or_else(|poisoned| poisoned.into_inner());
21939            bindings
21940                .get_mut(&address.session.instance_id)
21941                .unwrap()
21942                .managed_worktree_lease_id = Some(lease.lease_id.clone());
21943        }
21944        let lease_snapshot = shared
21945            .managed_worktrees
21946            .lock()
21947            .unwrap_or_else(|poisoned| poisoned.into_inner())
21948            .bind_session(
21949                &lease.lease_id,
21950                ManagedWorktreeSessionHolder {
21951                    incarnation_id: shared.incarnation_id,
21952                    instance_id: address.session.instance_id,
21953                    generation: address.session.generation,
21954                },
21955                None,
21956                unix_time_ms(),
21957            )
21958            .unwrap();
21959        assert_eq!(lease_snapshot.active_session_count, 1);
21960        assert_eq!(lease_snapshot.managed_record_count, 0);
21961        shared.persist_state().unwrap();
21962        assert!(shared
21963            .managed_worktrees
21964            .lock()
21965            .unwrap_or_else(|poisoned| poisoned.into_inner())
21966            .get(&lease.lease_id)
21967            .unwrap()
21968            .record_holders
21969            .is_empty());
21970        shared
21971            .handle
21972            .dispatch(shared.prepare_command(ControlCommand::Register {
21973                instance_id: address.session.instance_id,
21974                agent_id: agent("claude"),
21975                transport: TransportKind::Pty,
21976            }))
21977            .unwrap();
21978        runtime.tick().await;
21979        shared.reconcile_managed_record(
21980            &record_id,
21981            &address,
21982            &ControlEvent {
21983                sequence: 1,
21984                command_id: None,
21985                instance_id: address.session.instance_id,
21986                generation: address.session.generation,
21987                event: ControlEventKind::Removed,
21988            },
21989            false,
21990            false,
21991        );
21992        let remove = shared.remove_session(&address);
21993        let drive_runtime = async {
21994            runtime.tick().await;
21995        };
21996        let (removed, ()) = tokio::join!(remove, drive_runtime);
21997        removed.unwrap();
21998
21999        assert!(!materialization_root.exists());
22000        assert!(!Path::new(&created.path).exists());
22001        assert_eq!(
22002            shared
22003                .managed_worktrees
22004                .lock()
22005                .unwrap_or_else(|poisoned| poisoned.into_inner())
22006                .get(&lease.lease_id)
22007                .unwrap()
22008                .state,
22009            ManagedWorktreeLeaseState::Removed,
22010        );
22011        let renamed = shared
22012            .rename_session_record(&record_id, "raw managed inventory".to_owned())
22013            .unwrap();
22014        assert_eq!(renamed.record_id, record_id);
22015        assert_eq!(renamed.display_name, "raw managed inventory");
22016        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22017        assert!(renamed.active_session.is_none());
22018
22019        drop(shared);
22020        drop(runtime);
22021        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22022        std::fs::remove_dir_all(git_root).unwrap();
22023    }
22024
22025    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22026        let root = std::env::current_dir().unwrap()
22027            .join("managed-test-target")
22028            .to_string_lossy()
22029            .into_owned();
22030        let lease = ManagedWorktreeLeaseRecord {
22031            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22032            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22033            workspace_id: WorkspaceId::new("managed-test").unwrap(),
22034            profile_id: WorktreeProfileId::new("default").unwrap(),
22035            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22036            target_root: root.clone(),
22037            branch: "gate4agent/mw-test".to_owned(),
22038            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22039            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22040            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22041            state: ManagedWorktreeLeaseState::Ready,
22042            session_holders: Vec::new(),
22043            record_holders: Vec::new(),
22044            cleanup_failure: None,
22045            created_at_unix_ms: 1,
22046            updated_at_unix_ms: 1,
22047        };
22048        shared.workspaces.write()
22049            .unwrap_or_else(|poisoned| poisoned.into_inner())
22050            .insert(lease.workspace_id.clone(), root);
22051        *shared.managed_worktrees.lock()
22052            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22053            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22054        lease
22055    }
22056
22057    #[cfg(feature = "fixture")]
22058    #[test]
22059    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22060        let mut shared = terminal_test_shared();
22061        shared.fixture_semantic_hook_policy = true;
22062        let lease = install_managed_lease(&shared);
22063        let address = SessionAddress {
22064            workspace_id: lease.workspace_id.clone(),
22065            session: SessionKey {
22066                instance_id: AgentInstanceId(206),
22067                generation: SessionGeneration(1),
22068            },
22069        };
22070        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22071        let effective_runtime_policy =
22072            shared.effective_spawn_runtime_policy(pre_admission_policy);
22073        assert_ne!(effective_runtime_policy, pre_admission_policy);
22074        assert!(effective_runtime_policy.provider_session_identity);
22075
22076        let record_id = shared
22077            .bind_spawn_session(
22078                &address,
22079                agent("claude"),
22080                SessionMode::Pty,
22081                effective_runtime_policy,
22082                SpawnRecordPolicy::Always,
22083                None,
22084            )
22085            .unwrap()
22086            .unwrap();
22087        assert_eq!(
22088            shared.record(&record_id).unwrap().state,
22089            ManagedSessionState::IdentityPending,
22090        );
22091        let (record_holder, raw_inventory_record_id) = {
22092            let mut bindings = shared
22093                .session_bindings
22094                .lock()
22095                .unwrap_or_else(|poisoned| poisoned.into_inner());
22096            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22097            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22098            managed_spawn_record_ownership(binding, effective_runtime_policy)
22099        };
22100        assert_eq!(record_holder.as_ref(), Some(&record_id));
22101        assert!(raw_inventory_record_id.is_none());
22102        let snapshot = shared
22103            .managed_worktrees
22104            .lock()
22105            .unwrap_or_else(|poisoned| poisoned.into_inner())
22106            .bind_session(
22107                &lease.lease_id,
22108                ManagedWorktreeSessionHolder {
22109                    incarnation_id: shared.incarnation_id,
22110                    instance_id: address.session.instance_id,
22111                    generation: address.session.generation,
22112                },
22113                record_holder,
22114                unix_time_ms(),
22115            )
22116            .unwrap();
22117        assert_eq!(snapshot.managed_record_count, 1);
22118        assert_eq!(
22119            shared
22120                .managed_worktrees
22121                .lock()
22122                .unwrap_or_else(|poisoned| poisoned.into_inner())
22123                .get(&lease.lease_id)
22124                .unwrap()
22125                .record_holders,
22126            vec![record_id.clone()],
22127        );
22128
22129        let identity = ProviderSessionIdentity {
22130            key: ProviderSessionKey::SessionId,
22131            id: "monitoring-fixture-managed-session".to_owned(),
22132            transcript_path: None,
22133        };
22134        shared.publish_control(ControlEvent {
22135            sequence: 1,
22136            command_id: None,
22137            instance_id: address.session.instance_id,
22138            generation: address.session.generation,
22139            event: ControlEventKind::ProviderEvent {
22140                sequence: 1,
22141                source: gate4agent_types::ProviderSource {
22142                    family: AdapterFamily::Hook,
22143                    binding: AdapterBinding::new(
22144                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
22145                        "fixture/v1",
22146                        gate4agent_types::AdapterVerification::SyntheticFixture,
22147                    )
22148                    .unwrap(),
22149                },
22150                source_sequence: 1,
22151                event: ProviderEvent::SessionIdentityObserved {
22152                    identity: identity.clone(),
22153                },
22154            },
22155        });
22156
22157        let live = shared.record(&record_id).unwrap();
22158        assert_eq!(live.state, ManagedSessionState::Live);
22159        assert_eq!(live.provider_session.as_ref(), Some(&identity));
22160        assert_eq!(live.active_session.as_ref(), Some(&address));
22161        assert_eq!(
22162            shared
22163                .managed_worktrees
22164                .lock()
22165                .unwrap_or_else(|poisoned| poisoned.into_inner())
22166                .get(&lease.lease_id)
22167                .unwrap()
22168                .record_holders,
22169            vec![record_id],
22170        );
22171    }
22172
22173    fn managed_v2_test_shared(
22174        allocation_root: &Path,
22175        state_path: Option<PathBuf>,
22176        profile_revision: &str,
22177    ) -> NodeShared {
22178        let profile = ManagedWorktreeProfile::new(
22179            WorktreeProfileId::new("default").unwrap(),
22180            WorktreeProfileRevision::new(profile_revision).unwrap(),
22181            allocation_root,
22182            "gate4agent",
22183            "HEAD",
22184            ManagedWorktreeRetention::RemoveWhenReleased,
22185        )
22186        .unwrap();
22187        let workspace = WorkspaceConfig::new(
22188            WorkspaceId::new("primary").unwrap(),
22189            std::env::current_dir().unwrap(),
22190        )
22191        .unwrap()
22192        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22193        .with_managed_worktree_profile(profile)
22194        .unwrap();
22195        let catalog = active_registry().unwrap();
22196        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22197        let mut shared = NodeShared::new(
22198            handle,
22199            "fixture-token".to_owned(),
22200            NodeId::new("node-terminal-test").unwrap(),
22201            vec![workspace],
22202            vec![agent("claude")],
22203        );
22204        shared.state_path = state_path;
22205        shared
22206    }
22207
22208    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22209        let mut spawn_spec = spawn_spec_fixture();
22210        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22211        ManagedWorktreeSpawnRequestV2 {
22212            spawn_spec,
22213            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22214            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22215        }
22216    }
22217
22218    #[test]
22219    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22220        let shared = terminal_test_shared();
22221        let mut lease = install_managed_lease(&shared);
22222        let session = SessionAddress {
22223            workspace_id: lease.workspace_id.clone(),
22224            session: SessionKey {
22225                instance_id: AgentInstanceId(73),
22226                generation: SessionGeneration(4),
22227            },
22228        };
22229        lease.state = ManagedWorktreeLeaseState::InUse;
22230        lease.session_holders.push(ManagedWorktreeSessionHolder {
22231            incarnation_id: shared.incarnation_id,
22232            instance_id: session.session.instance_id,
22233            generation: session.session.generation,
22234        });
22235        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22236        assert!(resolved.target.worktree_id.is_none());
22237
22238        let receipt = managed_spawn_receipt(
22239            &resolved,
22240            shared.incarnation_id,
22241            session.clone(),
22242            lease.snapshot(),
22243            None,
22244            None,
22245            None,
22246        );
22247        assert!(resolved.target.worktree_id.is_none());
22248        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22249        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22250        assert_eq!(receipt.spawn.session, session);
22251        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22252        let encoded = serde_json::to_vec(&receipt).unwrap();
22253        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22254        assert_eq!(decoded, receipt);
22255    }
22256
22257    #[tokio::test]
22258    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22259        let root = temporary_workspace_root("managed-v2-revision");
22260        let allocation = root.join("allocation");
22261        std::fs::create_dir_all(&allocation).unwrap();
22262        let shared = managed_v2_test_shared(
22263            &allocation,
22264            Some(root.join("node-state.json")),
22265            "v1",
22266        );
22267        let mut request = managed_v2_request("managed-v2-revision");
22268        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22269
22270        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22271
22272        assert_eq!(
22273            failure.code,
22274            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22275        );
22276        assert!(shared
22277            .managed_worktrees
22278            .lock()
22279            .unwrap_or_else(|poisoned| poisoned.into_inner())
22280            .records()
22281            .is_empty());
22282        assert!(shared
22283            .managed_spawn_replays
22284            .lock()
22285            .unwrap_or_else(|poisoned| poisoned.into_inner())
22286            .is_empty());
22287        std::fs::remove_dir_all(root).unwrap();
22288    }
22289
22290    #[test]
22291    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22292        let root = temporary_workspace_root("managed-v2-concurrent");
22293        let allocation = root.join("allocation");
22294        std::fs::create_dir_all(&allocation).unwrap();
22295        let shared = Arc::new(managed_v2_test_shared(
22296            &allocation,
22297            Some(root.join("node-state.json")),
22298            "v1",
22299        ));
22300        let request = managed_v2_request("managed-v2-concurrent");
22301        let barrier = Arc::new(std::sync::Barrier::new(3));
22302        let mut joins = Vec::new();
22303        for _ in 0..2 {
22304            let shared = Arc::clone(&shared);
22305            let request = request.clone();
22306            let barrier = Arc::clone(&barrier);
22307            joins.push(std::thread::spawn(move || {
22308                let digest = managed_spawn_request_digest_v2(&request).unwrap();
22309                barrier.wait();
22310                shared.acquire_durable_managed_spawn_v2(&request, digest)
22311            }));
22312        }
22313        barrier.wait();
22314        let results = joins
22315            .into_iter()
22316            .map(|join| join.join().unwrap())
22317            .collect::<Vec<_>>();
22318        assert_eq!(
22319            results
22320                .iter()
22321                .filter(|result| {
22322                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22323                })
22324                .count(),
22325            1,
22326        );
22327        assert_eq!(
22328            results
22329                .iter()
22330                .filter(|result| {
22331                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22332                })
22333                .count(),
22334            1,
22335        );
22336        assert_eq!(
22337            shared
22338                .managed_spawn_replays
22339                .lock()
22340                .unwrap_or_else(|poisoned| poisoned.into_inner())
22341                .len(),
22342            1,
22343        );
22344        assert!(shared
22345            .managed_worktrees
22346            .lock()
22347            .unwrap_or_else(|poisoned| poisoned.into_inner())
22348            .records()
22349            .is_empty());
22350        drop(shared);
22351        std::fs::remove_dir_all(root).unwrap();
22352    }
22353
22354    #[tokio::test]
22355    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22356        let root = temporary_workspace_root("managed-v2-linked-pending");
22357        let allocation = root.join("allocation");
22358        std::fs::create_dir_all(&allocation).unwrap();
22359        let state_path = root.join("node-state.json");
22360        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22361        let lease = install_managed_lease(&shared);
22362        let request = managed_v2_request("managed-v2-linked-pending");
22363        assert!(matches!(
22364            shared
22365                .acquire_durable_managed_spawn_v2(
22366                    &request,
22367                    managed_spawn_request_digest_v2(&request).unwrap(),
22368                )
22369                .unwrap(),
22370            DurableManagedSpawnReplayDecision::Reserved,
22371        ));
22372        {
22373            let _transaction = shared
22374                .state_transaction
22375                .lock()
22376                .unwrap_or_else(|poisoned| poisoned.into_inner());
22377            shared
22378                .link_durable_managed_spawn_v2_lease_locked(
22379                    &request.spawn_spec.idempotency_key,
22380                    &lease.lease_id,
22381                )
22382                .unwrap();
22383            shared.persist_state_locked().unwrap();
22384        }
22385        drop(shared);
22386
22387        let mut loaded = session_registry::load(
22388            Some(&state_path),
22389            &NodeId::new("node-terminal-test").unwrap(),
22390        )
22391        .unwrap();
22392        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22393        *reopened
22394            .managed_worktrees
22395            .lock()
22396            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22397            ManagedWorktreeRegistry::from_records(
22398                std::mem::take(&mut loaded.managed_worktrees),
22399                std::mem::take(&mut loaded.managed_worktree_tombstones),
22400            )
22401            .unwrap();
22402        *reopened
22403            .managed_spawn_replays
22404            .lock()
22405            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22406            .managed_spawn_replays
22407            .into_iter()
22408            .map(|record| (record.idempotency_key.clone(), record))
22409            .collect();
22410        let lease_count = reopened
22411            .managed_worktrees
22412            .lock()
22413            .unwrap_or_else(|poisoned| poisoned.into_inner())
22414            .records()
22415            .len();
22416
22417        let failure = reopened
22418            .spawn_managed_worktree_v2(request)
22419            .await
22420            .unwrap_err();
22421
22422        assert_eq!(
22423            failure.code,
22424            NodeFailureCode::ManagedWorktreeRecoveryRequired,
22425        );
22426        assert_eq!(
22427            reopened
22428                .managed_worktrees
22429                .lock()
22430                .unwrap_or_else(|poisoned| poisoned.into_inner())
22431                .records()
22432                .len(),
22433            lease_count,
22434        );
22435        std::fs::remove_dir_all(root).unwrap();
22436    }
22437
22438    #[tokio::test]
22439    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22440        let root = temporary_workspace_root("managed-v2-reopen");
22441        let allocation = root.join("allocation");
22442        std::fs::create_dir_all(&allocation).unwrap();
22443        let state_path = root.join("node-state.json");
22444        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22445        let lease = install_managed_lease(&shared);
22446        let request = managed_v2_request("managed-v2-reopen");
22447        let session = SessionAddress {
22448            workspace_id: lease.workspace_id.clone(),
22449            session: SessionKey {
22450                instance_id: AgentInstanceId(91),
22451                generation: SessionGeneration(1),
22452            },
22453        };
22454        let snapshot = {
22455            let mut registry = shared
22456                .managed_worktrees
22457                .lock()
22458                .unwrap_or_else(|poisoned| poisoned.into_inner());
22459            let current = registry.get_mut(&lease.lease_id).unwrap();
22460            current.state = ManagedWorktreeLeaseState::InUse;
22461            current.session_holders.push(ManagedWorktreeSessionHolder {
22462                incarnation_id: shared.incarnation_id,
22463                instance_id: session.session.instance_id,
22464                generation: session.session.generation,
22465            });
22466            current.snapshot()
22467        };
22468        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22469        let receipt = managed_spawn_receipt(
22470            &resolved,
22471            shared.incarnation_id,
22472            session,
22473            snapshot,
22474            None,
22475            None,
22476            None,
22477        );
22478        assert!(matches!(
22479            shared
22480                .acquire_durable_managed_spawn_v2(
22481                    &request,
22482                    managed_spawn_request_digest_v2(&request).unwrap(),
22483                )
22484                .unwrap(),
22485            DurableManagedSpawnReplayDecision::Reserved,
22486        ));
22487        {
22488            let _transaction = shared
22489                .state_transaction
22490                .lock()
22491                .unwrap_or_else(|poisoned| poisoned.into_inner());
22492            shared
22493                .link_durable_managed_spawn_v2_lease_locked(
22494                    &request.spawn_spec.idempotency_key,
22495                    &lease.lease_id,
22496                )
22497                .unwrap();
22498            shared.persist_state_locked().unwrap();
22499        }
22500        shared
22501            .commit_durable_managed_spawn_v2(&request, receipt.clone())
22502            .unwrap();
22503        let legacy_same_key = ManagedWorktreeSpawnRequest {
22504            spawn_spec: request.spawn_spec.clone(),
22505            worktree_profile_id: request.worktree_profile_id.clone(),
22506        };
22507        shared.remember_managed_spawn_attempt(
22508            Some(&request.spawn_spec.idempotency_key),
22509            legacy_same_key.clone(),
22510            Ok(receipt.clone()),
22511            Instant::now(),
22512        );
22513        assert!(shared
22514            .replay_managed_spawn(&legacy_same_key, Instant::now())
22515            .unwrap()
22516            .is_none());
22517        drop(shared);
22518
22519        let mut loaded = session_registry::load(
22520            Some(&state_path),
22521            &NodeId::new("node-terminal-test").unwrap(),
22522        )
22523        .unwrap();
22524        assert_eq!(loaded.managed_spawn_replays.len(), 1);
22525        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22526        *reopened
22527            .managed_worktrees
22528            .lock()
22529            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22530            ManagedWorktreeRegistry::from_records(
22531                std::mem::take(&mut loaded.managed_worktrees),
22532                std::mem::take(&mut loaded.managed_worktree_tombstones),
22533            )
22534            .unwrap();
22535        *reopened
22536            .managed_spawn_replays
22537            .lock()
22538            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22539            .managed_spawn_replays
22540            .into_iter()
22541            .map(|record| (record.idempotency_key.clone(), record))
22542            .collect();
22543        let lease_count_before = reopened
22544            .managed_worktrees
22545            .lock()
22546            .unwrap_or_else(|poisoned| poisoned.into_inner())
22547            .records()
22548            .len();
22549
22550        assert_eq!(
22551            reopened
22552                .spawn_managed_worktree_v2(request.clone())
22553                .await
22554                .unwrap(),
22555            receipt,
22556        );
22557        assert_eq!(
22558            reopened
22559                .managed_worktrees
22560                .lock()
22561                .unwrap_or_else(|poisoned| poisoned.into_inner())
22562                .records()
22563                .len(),
22564            lease_count_before,
22565        );
22566        let mut changed = request;
22567        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22568        let failure = reopened
22569            .spawn_managed_worktree_v2(changed)
22570            .await
22571            .unwrap_err();
22572        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22573        assert_eq!(
22574            reopened
22575                .managed_worktrees
22576                .lock()
22577                .unwrap_or_else(|poisoned| poisoned.into_inner())
22578                .records()
22579                .len(),
22580            lease_count_before,
22581        );
22582        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22583        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22584        let failure = reopened
22585            .spawn_managed_worktree_v2(new_key)
22586            .await
22587            .unwrap_err();
22588        assert_eq!(
22589            failure.code,
22590            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22591        );
22592        assert_eq!(
22593            reopened
22594                .managed_worktrees
22595                .lock()
22596                .unwrap_or_else(|poisoned| poisoned.into_inner())
22597                .records()
22598                .len(),
22599            lease_count_before,
22600        );
22601        assert_eq!(
22602            reopened
22603                .managed_spawn_replays
22604                .lock()
22605                .unwrap_or_else(|poisoned| poisoned.into_inner())
22606                .len(),
22607            1,
22608        );
22609        std::fs::remove_dir_all(root).unwrap();
22610    }
22611
22612    #[test]
22613    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22614        let shared = terminal_test_shared();
22615        let request = ManagedWorktreeSpawnRequest {
22616            spawn_spec: spawn_spec_fixture(),
22617            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22618        };
22619        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22620        shared.remember_managed_spawn(
22621            request.clone(),
22622            Err(expected.clone()),
22623            Instant::now(),
22624        );
22625
22626        assert_eq!(
22627            shared
22628                .replay_managed_spawn(&request, Instant::now())
22629                .unwrap()
22630                .unwrap(),
22631            Err(expected),
22632        );
22633        assert!(shared
22634            .managed_spawn_replays
22635            .lock()
22636            .unwrap_or_else(|poisoned| poisoned.into_inner())
22637            .is_empty());
22638    }
22639
22640    #[tokio::test]
22641    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22642        let shared = terminal_test_shared();
22643        let lease = install_managed_lease(&shared);
22644        let raw = shared.spawn_session(
22645            lease.workspace_id.clone(),
22646            agent("claude"),
22647            SessionMode::Pty,
22648            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22649            None,
22650        ).await.unwrap_err();
22651        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22652        let mut spec = spawn_spec_fixture();
22653        spec.target.workspace_id = lease.workspace_id.clone();
22654        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22655        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22656        let mut selected = spawn_spec_fixture();
22657        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22658        selected.target.worktree_id = Some(lease.workspace_id);
22659        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22660        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22661        assert!(shared.handle.snapshot().sessions.is_empty());
22662    }
22663
22664    #[test]
22665    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22666        let shared = terminal_test_shared();
22667        let lease = install_managed_lease(&shared);
22668        let snapshot = snapshot_for_wire(
22669            &shared, true, true, true, false, true, true, false, false, true,
22670        );
22671        assert!(snapshot.managed_worktrees.is_empty());
22672        assert!(snapshot.workspaces.iter().all(|workspace| {
22673            workspace.managed_worktree_profiles.is_none()
22674        }));
22675        let mut reply = ResponseEnvelope {
22676            request_id: 1,
22677            result: Ok(NodeResponse::Resync {
22678                event_sequence: 1,
22679                oldest_available_sequence: 1,
22680                snapshot: shared.snapshot(),
22681                events: vec![NodeEventEnvelope {
22682                    sequence: 1,
22683                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22684                }],
22685            }),
22686        };
22687        project_response_without_managed_worktrees(&mut reply);
22688        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22689            panic!("projection changed the response kind");
22690        };
22691        assert!(snapshot.managed_worktrees.is_empty());
22692        assert!(events.is_empty());
22693        let mut git = git_snapshot_for_parser();
22694        git.branch = Some(lease.branch.clone());
22695        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22696            lease_id: lease.lease_id.clone(),
22697            source_workspace_id: lease.source_workspace_id.clone(),
22698            branch: lease.branch.clone(),
22699            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22700            active_session_count: 1,
22701            managed_record_count: 0,
22702        });
22703        let mut inspection_reply = ResponseEnvelope {
22704            request_id: 2,
22705            result: Ok(NodeResponse::WorkspaceInspected {
22706                inspection: WorkspaceInspection {
22707                    workspace_id: lease.workspace_id.clone(),
22708                    entries: Vec::new(),
22709                    tree_truncated: false,
22710                    git,
22711                    truncation: None,
22712                },
22713            }),
22714        };
22715        project_response_without_managed_worktrees(&mut inspection_reply);
22716        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22717            panic!("projection changed the inspection response kind");
22718        };
22719        assert!(inspection.git.managed_worktree.is_none());
22720        let failure = managed_git_worktree_failure(GitWorktreeError {
22721            kind: GitWorktreeErrorKind::Failed,
22722            message: r"git failed at C:\private\managed-target".to_owned(),
22723        });
22724        assert!(!failure.message.contains("private"));
22725        assert!(!failure.message.contains("managed-target"));
22726    }
22727
22728    #[tokio::test]
22729    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22730        let root = temporary_workspace_root("managed-restart");
22731        let source = root.join("source");
22732        let allocation = root.join("allocation");
22733        std::fs::create_dir_all(&source).unwrap();
22734        std::fs::create_dir_all(&allocation).unwrap();
22735        run_test_git(&source, &["init"]);
22736        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22737        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22738        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22739        run_test_git(&source, &["add", "seed.txt"]);
22740        run_test_git(&source, &["commit", "-m", "seed"]);
22741        let profile = ManagedWorktreeProfile::new(
22742            WorktreeProfileId::new("default").unwrap(),
22743            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22744            &allocation,
22745            "gate4agent",
22746            "HEAD",
22747            ManagedWorktreeRetention::Retain,
22748        ).unwrap();
22749        let workspace = WorkspaceConfig::new(
22750            WorkspaceId::new("primary").unwrap(),
22751            &source,
22752        ).unwrap()
22753            .with_worktree_service_mode(WorktreeServiceMode::Managed)
22754            .with_managed_worktree_profile(profile.clone()).unwrap();
22755        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22756        let source_text = workspace.canonical_root().to_owned();
22757        let target_text = target.to_string_lossy().into_owned();
22758        let created = create_git_worktree(
22759            &source_text,
22760            &target_text,
22761            "gate4agent/mw-restart",
22762            Some("HEAD"),
22763        ).await.unwrap();
22764        let lease = ManagedWorktreeLeaseRecord {
22765            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22766            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22767            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22768            profile_id: profile.profile_id().clone(),
22769            profile_revision: profile.revision().clone(),
22770            target_root: created.path.clone(),
22771            branch: "gate4agent/mw-restart".to_owned(),
22772            base_commit: created.head.clone(),
22773            expected_head: None,
22774            retention: ManagedWorktreeRetention::Retain,
22775            state: ManagedWorktreeLeaseState::Allocating,
22776            session_holders: Vec::new(),
22777            record_holders: Vec::new(),
22778            cleanup_failure: None,
22779            created_at_unix_ms: 1,
22780            updated_at_unix_ms: 1,
22781        };
22782        let catalog = active_registry().unwrap();
22783        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22784        let shared = NodeShared::new_with_incarnation(
22785            handle,
22786            "fixture-token".to_owned(),
22787            NodeId::new("node-managed-restart").unwrap(),
22788            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22789            vec![workspace],
22790            vec![agent("claude")],
22791            ProviderRuntimeStatuses::default(),
22792            None,
22793            Vec::new(),
22794            Vec::new(),
22795            SpawnProfileRegistry::default(),
22796            None,
22797            None,
22798            None,
22799            None,
22800            Vec::new(),
22801            vec![lease.clone()],
22802            Vec::new(),
22803            Vec::new(),
22804            None,
22805            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22806        );
22807        shared.reconcile_managed_worktrees().await;
22808        let retained = shared.managed_worktrees.lock()
22809            .unwrap_or_else(|poisoned| poisoned.into_inner())
22810            .get(&lease.lease_id).unwrap().clone();
22811        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22812        assert!(shared.workspaces.read()
22813            .unwrap_or_else(|poisoned| poisoned.into_inner())
22814            .contains_key(&lease.workspace_id));
22815        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22816        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22817        assert!(!Path::new(&created.path).exists());
22818        std::fs::remove_dir_all(root).unwrap();
22819    }
22820
22821    fn run_test_git(root: &Path, arguments: &[&str]) {
22822        let output = std::process::Command::new("git")
22823            .arg("-C").arg(root).args(arguments).output().unwrap();
22824        assert!(
22825            output.status.success(),
22826            "git {:?} failed: {}",
22827            arguments,
22828            String::from_utf8_lossy(&output.stderr),
22829        );
22830    }
22831
22832    #[tokio::test]
22833    async fn spawn_spec_validation_fails_before_session_mutation() {
22834        let shared = terminal_test_shared();
22835        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22836            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22837        }));
22838        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22839            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22840        }));
22841        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22842            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22843        }));
22844
22845        let mut target_mismatch = spawn_spec_fixture();
22846        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22847        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22848        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22849
22850        let mut unknown_profile = spawn_spec_fixture();
22851        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22852        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22853        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22854        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22855
22856        let mut revision_mismatch = spawn_spec_fixture();
22857        revision_mismatch.idempotency_key =
22858            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22859        revision_mismatch.expected_profile_revision =
22860            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22861        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22862        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22863
22864        let mut unsupported_materializer = spawn_spec_fixture();
22865        unsupported_materializer.idempotency_key =
22866            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22867        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22868            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22869        };
22870        let failure = shared
22871            .spawn_from_spec(unsupported_materializer)
22872            .await
22873            .unwrap_err();
22874        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22875
22876        let failure = shared
22877            .spawn_session_with_deadline(
22878                WorkspaceId::new("primary").unwrap(),
22879                agent("claude"),
22880                SessionMode::Pty,
22881                gate4agent_types::TerminalSize {
22882                    rows: 24,
22883                    columns: 80,
22884                },
22885                None,
22886                None,
22887                None,
22888                None,
22889                None,
22890                None,
22891                SpawnRecordPolicy::ProviderIdentityOnly,
22892                Some(Instant::now()),
22893                &[],
22894                None,
22895                ApprovalLevel::default(),
22896                None,
22897                None,
22898            )
22899            .await
22900            .unwrap_err();
22901        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22902        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22903        assert!(shared.handle.snapshot().sessions.is_empty());
22904    }
22905
22906    #[test]
22907    fn worktree_selection_requires_its_separate_negotiated_capability() {
22908        let mut spec = spawn_spec_fixture();
22909        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22910        let request = NodeRequest::SpawnSpec { spec };
22911        let spawn_spec_capability =
22912            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22913        assert!(request_uses_unnegotiated_capability(
22914            &request,
22915            &[spawn_spec_capability.clone()],
22916        ));
22917        let worktree_capability =
22918            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22919        assert!(request_uses_unnegotiated_capability(
22920            &request,
22921            &[spawn_spec_capability.clone(), worktree_capability.clone()],
22922        ));
22923        let profile_revision_capability =
22924            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22925        assert!(request_uses_unnegotiated_capability(
22926            &request,
22927            &[
22928                spawn_spec_capability.clone(),
22929                worktree_capability.clone(),
22930                profile_revision_capability.clone(),
22931            ],
22932        ));
22933        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
22934        // `Inherit`, not `Clear` — the profile's own defaults might still
22935        // materialize a bundle or a context pack, so both capabilities stay
22936        // conservatively required until negotiated (see
22937        // `requires_session_bundle_materialization_capability` /
22938        // `requires_history_context_pack_capability`).
22939        let bundle_materialization_capability =
22940            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22941        assert!(request_uses_unnegotiated_capability(
22942            &request,
22943            &[
22944                spawn_spec_capability.clone(),
22945                worktree_capability.clone(),
22946                profile_revision_capability.clone(),
22947                bundle_materialization_capability.clone(),
22948            ],
22949        ));
22950        let history_context_pack_capability =
22951            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22952        assert!(!request_uses_unnegotiated_capability(
22953            &request,
22954            &[
22955                spawn_spec_capability,
22956                worktree_capability,
22957                profile_revision_capability,
22958                bundle_materialization_capability,
22959                history_context_pack_capability,
22960            ],
22961        ));
22962    }
22963
22964    #[tokio::test]
22965    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22966        let mut shared = terminal_test_shared();
22967        let spec = spawn_spec_fixture();
22968        let receipt = shared
22969            .resolve_spawn_spec(&spec)
22970            .unwrap()
22971            .receipt(shared.incarnation_id, terminal_address(1));
22972        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22973
22974        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22975        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22976        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22977
22978        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22979
22980        let mut revision_conflicting = spec.clone();
22981        revision_conflicting.expected_profile_revision =
22982            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22983        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22984        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22985
22986        let mut stale_cache_miss = spec.clone();
22987        stale_cache_miss.idempotency_key =
22988            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22989        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22990        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22991
22992        let mut conflicting = spec;
22993        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22994        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22995        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22996        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22997        assert!(shared.handle.snapshot().sessions.is_empty());
22998    }
22999
23000    #[tokio::test]
23001    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23002        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23003        let mut spec = spawn_spec_fixture();
23004        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23005        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23006        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23007
23008        let failure = shared
23009            .create_worktree(
23010                WorkspaceId::new("primary").unwrap(),
23011                WorkspaceId::new("new-worktree").unwrap(),
23012                "not-an-absolute-path".to_owned(),
23013                "invalid branch".to_owned(),
23014                None,
23015            )
23016            .await
23017            .unwrap_err();
23018        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23019
23020        let failure = shared
23021            .remove_worktree(
23022                WorkspaceId::new("primary").unwrap(),
23023                "not-an-absolute-path".to_owned(),
23024            )
23025            .await
23026            .unwrap_err();
23027        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23028        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23029        assert!(shared.handle.snapshot().sessions.is_empty());
23030    }
23031
23032    #[tokio::test]
23033    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23034        let root = temporary_workspace_root("manual-worktree-compensation");
23035        let source = root.join("source");
23036        let target = root.join("target");
23037        let invalid_state_parent = root.join("state-parent-is-a-file");
23038        let invalid_state_path = invalid_state_parent.join("state.json");
23039        std::fs::create_dir_all(&source).unwrap();
23040        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23041        run_test_git(&source, &["init"]);
23042        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23043        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23044        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23045        run_test_git(&source, &["add", "seed.txt"]);
23046        run_test_git(&source, &["commit", "-m", "seed"]);
23047
23048        let catalog = active_registry().unwrap();
23049        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23050        let workspace = WorkspaceConfig::new(
23051            WorkspaceId::new("primary").unwrap(),
23052            &source,
23053        ).unwrap();
23054        let mut shared = NodeShared::new(
23055            handle,
23056            "fixture-token".to_owned(),
23057            NodeId::new("node-worktree-compensation").unwrap(),
23058            vec![workspace],
23059            vec![agent("claude")],
23060        );
23061        shared.state_path = Some(invalid_state_path);
23062
23063        let error = shared.create_worktree(
23064            WorkspaceId::new("primary").unwrap(),
23065            WorkspaceId::new("compensated").unwrap(),
23066            target.to_string_lossy().into_owned(),
23067            "gate4agent/compensated".to_owned(),
23068            Some("HEAD".to_owned()),
23069        ).await.unwrap_err();
23070
23071        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23072        assert!(!target.exists());
23073        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23074            .iter()
23075            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23076        std::fs::remove_dir_all(root).unwrap();
23077    }
23078
23079    #[test]
23080    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23081        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23082            NativeGitWorktreeSnapshot {
23083                path: path.to_owned(),
23084                head: "0123456789abcdef".to_owned(),
23085                branch: Some("refs/heads/test".to_owned()),
23086                is_bare,
23087                is_main,
23088                locked: false,
23089                lock_reason: None,
23090                prunable: false,
23091                prunable_reason: None,
23092                workspace_id: None,
23093            }
23094        }
23095
23096        let source = r"C:\repo";
23097        let selected = r"C:\repo-worktree";
23098        let listed = vec![
23099            worktree(source, true, false),
23100            worktree(selected, false, false),
23101        ];
23102        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23103
23104        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23105        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23106
23107        let bare = vec![
23108            worktree(source, true, false),
23109            worktree(selected, false, true),
23110        ];
23111        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23112        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23113
23114        let foreign_failure =
23115            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23116        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23117    }
23118
23119    fn terminal_frame(sequence: u64) -> TerminalFrame {
23120        TerminalFrame {
23121            sequence,
23122            size: gate4agent_types::TerminalSize {
23123                rows: 24,
23124                columns: 80,
23125            },
23126            cursor_row: 2,
23127            cursor_column: 3,
23128            contents: format!("frame-{sequence}"),
23129            formatted: vec![1, 2, 3],
23130            scrollback_formatted: Vec::new(),
23131            alternate_screen: false,
23132            mouse_protocol_enabled: false,
23133            mouse_protocol_encoding: Default::default(),
23134            produced_at_unix_ms: 0,
23135            screen_state: gate4agent_types::PtyScreenState::default(),
23136            bracketed_paste: None,
23137        }
23138    }
23139
23140    #[test]
23141    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23142        let shared = terminal_test_shared();
23143        let mut receiver = shared.terminal_event_tx.subscribe();
23144        let original = terminal_address(1);
23145
23146        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23147        let first = receiver.try_recv().unwrap();
23148        assert_eq!(first.len(), 1);
23149        assert!(matches!(
23150            &first[0].event,
23151            NodeEvent::TerminalFrame { address, frame }
23152                if address == &original && frame.sequence == 7
23153        ));
23154
23155        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23156        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23157        assert!(matches!(
23158            receiver.try_recv(),
23159            Err(broadcast::error::TryRecvError::Empty)
23160        ));
23161
23162        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23163        let advanced = receiver.try_recv().unwrap();
23164        assert_eq!(advanced[0].sequence, 2);
23165
23166        let resumed = terminal_address(2);
23167        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23168        let reset = receiver.try_recv().unwrap();
23169        assert_eq!(reset[0].sequence, 3);
23170        assert!(matches!(
23171            &reset[0].event,
23172            NodeEvent::TerminalFrame { address, frame }
23173                if address == &resumed && frame.sequence == 1
23174        ));
23175        shared.clear_terminal_frame_watermark(&resumed);
23176        assert!(!shared
23177            .terminal_frame_watermarks
23178            .lock()
23179            .unwrap_or_else(|poisoned| poisoned.into_inner())
23180            .contains_key(&resumed.session.instance_id));
23181    }
23182
23183    #[test]
23184    fn terminal_frame_batches_replace_without_entering_node_event_history() {
23185        let shared = terminal_test_shared();
23186        let mut receiver = shared.terminal_event_tx.subscribe();
23187        let address = terminal_address(1);
23188        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23189
23190        for sequence in 1..=count {
23191            shared.publish_terminal_frame_candidates(vec![(
23192                address.clone(),
23193                terminal_frame(sequence),
23194            )]);
23195        }
23196
23197        let history = shared
23198            .history
23199            .lock()
23200            .unwrap_or_else(|poisoned| poisoned.into_inner());
23201        assert!(history.events.is_empty());
23202        assert_eq!(history.last_sequence, count);
23203        assert_eq!(history.replay_floor_sequence, 1);
23204        drop(history);
23205        let NodeResponse::Resync {
23206            event_sequence,
23207            oldest_available_sequence,
23208            events,
23209            ..
23210        } = shared.resync(0) else {
23211            unreachable!("resync helper returned another response")
23212        };
23213        assert_eq!(event_sequence, count);
23214        assert_eq!(oldest_available_sequence, 1);
23215        assert!(events.is_empty());
23216        assert!(matches!(
23217            resync_required_event(&shared).event,
23218            NodeEvent::ResyncRequired {
23219                oldest_available_sequence: 1,
23220            }
23221        ));
23222        assert!(matches!(
23223            receiver.try_recv(),
23224            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23225        ));
23226        let latest = receiver.try_recv().unwrap();
23227        assert_eq!(latest.len(), 1);
23228        assert_eq!(latest[0].sequence, count);
23229    }
23230
23231    #[test]
23232    fn real_durable_eviction_advances_authoritative_replay_floor() {
23233        let shared = terminal_test_shared();
23234        for _ in 0..=NODE_EVENT_HISTORY_MAX {
23235            shared.publish(NodeEvent::ControllerChanged { controller: None });
23236        }
23237
23238        let NodeResponse::Resync {
23239            event_sequence,
23240            oldest_available_sequence,
23241            events,
23242            ..
23243        } = shared.resync(0) else {
23244            unreachable!("resync helper returned another response")
23245        };
23246        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23247        assert_eq!(oldest_available_sequence, 2);
23248        assert_eq!(events.first().map(|event| event.sequence), Some(2));
23249        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23250        assert!(matches!(
23251            resync_required_event(&shared).event,
23252            NodeEvent::ResyncRequired {
23253                oldest_available_sequence: 2,
23254            }
23255        ));
23256    }
23257
23258    #[test]
23259    fn legacy_terminal_frame_connection_has_no_live_subscription() {
23260        let shared = terminal_test_shared();
23261        let without_capability = terminal_event_subscription(&shared, false);
23262        let with_capability = terminal_event_subscription(&shared, true);
23263        assert!(without_capability.is_none());
23264        assert!(with_capability.is_some());
23265        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23266            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23267        }));
23268    }
23269
23270    #[test]
23271    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23272        let mut pending = Vec::new();
23273        let mut event_sequence = 0_u64;
23274        for frame_sequence in 1..=4 {
23275            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23276                event_sequence += 1;
23277                let mut address = terminal_address(1);
23278                address.session.instance_id = AgentInstanceId(instance);
23279                queue_connection_event(
23280                    &mut pending,
23281                    NodeEventEnvelope {
23282                        sequence: event_sequence,
23283                        event: NodeEvent::TerminalFrame {
23284                            address,
23285                            frame: terminal_frame(frame_sequence),
23286                        },
23287                    },
23288                    0,
23289                );
23290            }
23291        }
23292        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23293        pending.sort_unstable_by_key(|event| event.sequence);
23294
23295        let mut uninterrupted = pending.clone();
23296        let first_burst = connection_event_burst_len(uninterrupted.len());
23297        uninterrupted.drain(..first_burst);
23298        let second_burst = connection_event_burst_len(uninterrupted.len());
23299        uninterrupted.drain(..second_burst);
23300        assert!(uninterrupted.is_empty());
23301
23302        let (frame_tx, mut frame_rx) =
23303            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23304        frame_tx
23305            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23306                request_id: 77,
23307                request: NodeRequest::Snapshot,
23308            })))
23309            .unwrap();
23310        let burst_len = connection_event_burst_len(pending.len());
23311        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23312        pending.drain(..burst_len);
23313        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23314        assert!(matches!(
23315            frame_rx.try_recv(),
23316            Ok(Ok(ClientFrame::Request(RequestEnvelope {
23317                request_id: 77,
23318                request: NodeRequest::Snapshot,
23319            })))
23320        ));
23321    }
23322
23323    #[test]
23324    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23325        let mut pending = Vec::new();
23326        for sequence in [7_u64, 8] {
23327            let mut address = terminal_address(1);
23328            address.session.instance_id = AgentInstanceId(sequence);
23329            queue_connection_event(
23330                &mut pending,
23331                NodeEventEnvelope {
23332                    sequence,
23333                    event: NodeEvent::TerminalFrame {
23334                        address,
23335                        frame: terminal_frame(sequence),
23336                    },
23337                },
23338                0,
23339            );
23340        }
23341        let mut discard_through = 0;
23342        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23343        assert!(pending.is_empty());
23344
23345        let replayed = NodeEventEnvelope {
23346            sequence: 8,
23347            event: NodeEvent::TerminalFrame {
23348                address: terminal_address(1),
23349                frame: terminal_frame(8),
23350            },
23351        };
23352        let advanced = NodeEventEnvelope {
23353            sequence: 9,
23354            event: NodeEvent::TerminalFrame {
23355                address: terminal_address(2),
23356                frame: terminal_frame(1),
23357            },
23358        };
23359        queue_connection_event_batch(
23360            &mut pending,
23361            &[replayed, advanced.clone()],
23362            discard_through,
23363        );
23364        assert_eq!(pending, vec![advanced]);
23365    }
23366
23367    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23368        ManagedSessionRecord {
23369            record_id: SessionRecordId::new(record_id).unwrap(),
23370            display_name: format!("{provider} record"),
23371            provider: agent(provider),
23372            mode: SessionMode::Pty,
23373            state: ManagedSessionState::Unavailable,
23374            workspace_id: WorkspaceId::new("primary").unwrap(),
23375            canonical_root: opaque_windows_path(
23376                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23377            ),
23378            provider_session: None,
23379            active_session: None,
23380            environment_profile: None,
23381            bundle: None,
23382            context_id: None,
23383            context: None,
23384            exported_context: None,
23385            task_binding: None,
23386            created_at_unix_ms: 1,
23387            updated_at_unix_ms: 1,
23388            last_error: None,
23389        }
23390    }
23391
23392    fn retention_test_record(
23393        workspace_id: &str,
23394        record_id: &str,
23395        state: ManagedSessionState,
23396        updated_at_unix_ms: u64,
23397    ) -> ManagedSessionRecord {
23398        ManagedSessionRecord {
23399            record_id: SessionRecordId::new(record_id).unwrap(),
23400            display_name: format!("{record_id} display"),
23401            provider: agent("claude"),
23402            mode: SessionMode::Pty,
23403            state,
23404            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23405            canonical_root: opaque_windows_path(
23406                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23407            ),
23408            provider_session: None,
23409            active_session: None,
23410            environment_profile: None,
23411            bundle: None,
23412            context_id: None,
23413            context: None,
23414            exported_context: None,
23415            task_binding: None,
23416            created_at_unix_ms: updated_at_unix_ms,
23417            updated_at_unix_ms,
23418            last_error: None,
23419        }
23420    }
23421
23422    /// Two workspaces of three `Unavailable` records each, staggered by
23423    /// age, plus one never-touchable decoy per non-`Unavailable` state in
23424    /// `workspace-a` (all as old as that workspace's oldest real record --
23425    /// if the sweep's state gate ever leaked, these would be the first
23426    /// thing it wrongly retired). Ages, oldest to newest:
23427    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
23428    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
23429    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23430        vec![
23431            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23432            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23433            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23434            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23435            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23436            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23437            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23438            retention_test_record(
23439                "workspace-a",
23440                "decoy-pending",
23441                ManagedSessionState::IdentityPending,
23442                now - 500_000,
23443            ),
23444            retention_test_record(
23445                "workspace-a",
23446                "decoy-dormant",
23447                ManagedSessionState::Dormant,
23448                now - 500_000,
23449            ),
23450        ]
23451    }
23452
23453    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23454        let root = temporary_workspace_root("session-record-retention");
23455        std::fs::create_dir_all(&root).unwrap();
23456        let catalog = active_registry().unwrap();
23457        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23458        let workspace_a =
23459            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23460        let workspace_b =
23461            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23462        let mut shared = NodeShared::new(
23463            handle,
23464            "fixture-token".to_owned(),
23465            NodeId::new("node-retention-test").unwrap(),
23466            vec![workspace_a, workspace_b],
23467            vec![agent("claude")],
23468        );
23469        shared.state_path = Some(root.join("node-state-v10.json"));
23470        let now = unix_time_ms();
23471        for record in retention_fixture_records(now) {
23472            shared.insert_record(record).unwrap();
23473        }
23474        shared
23475            .persist_state_locked()
23476            .expect("fixture-only, empty-history persist against a fresh temp state path");
23477        (shared, runtime, root, now)
23478    }
23479
23480    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23481        shared
23482            .history
23483            .lock()
23484            .unwrap_or_else(|poisoned| poisoned.into_inner())
23485            .events
23486            .iter()
23487            .filter_map(|envelope| match &envelope.event {
23488                NodeEvent::SessionRecordRemoved { record_id } => {
23489                    Some(record_id.as_str().to_owned())
23490                }
23491                _ => None,
23492            })
23493            .collect()
23494    }
23495
23496    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23497        shared
23498            .session_records
23499            .lock()
23500            .unwrap_or_else(|poisoned| poisoned.into_inner())
23501            .records
23502            .keys()
23503            .map(|record_id| record_id.as_str().to_owned())
23504            .collect()
23505    }
23506
23507    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23508        values.iter().map(|value| (*value).to_owned()).collect()
23509    }
23510
23511    #[tokio::test]
23512    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23513        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23514        shared.session_record_retention = SessionRecordRetentionConfig {
23515            age_ms: 0,
23516            keep_per_workspace: 1,
23517        };
23518        shared.retire_unavailable_session_records().await;
23519
23520        assert_eq!(
23521            retention_remaining_record_ids(&shared),
23522            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23523        );
23524        assert_eq!(
23525            retention_removed_record_ids(&shared),
23526            owned(&["a1", "a2", "b1", "b2"]),
23527        );
23528        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23529
23530        drop(shared);
23531        drop(runtime);
23532        std::fs::remove_dir_all(root).unwrap();
23533    }
23534
23535    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
23536    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
23537    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
23538    /// 200s old against a 250s threshold) even though it fails the keep-N
23539    /// test too, proving retirement needs BOTH, not either.
23540    #[tokio::test]
23541    async fn session_record_retention_both_set_retires_only_the_intersection() {
23542        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23543        shared.session_record_retention = SessionRecordRetentionConfig {
23544            age_ms: 250_000,
23545            keep_per_workspace: 1,
23546        };
23547        shared.retire_unavailable_session_records().await;
23548
23549        assert_eq!(
23550            retention_remaining_record_ids(&shared),
23551            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23552        );
23553        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23554        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23555
23556        drop(shared);
23557        drop(runtime);
23558        std::fs::remove_dir_all(root).unwrap();
23559    }
23560
23561    #[tokio::test]
23562    async fn session_record_retention_both_disabled_is_a_no_op() {
23563        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23564        shared.session_record_retention = SessionRecordRetentionConfig::default();
23565        shared.retire_unavailable_session_records().await;
23566
23567        assert_eq!(
23568            retention_remaining_record_ids(&shared),
23569            owned(&[
23570                "a1", "a2", "a3", "b1", "b2", "b3",
23571                "decoy-live", "decoy-pending", "decoy-dormant",
23572            ]),
23573        );
23574        assert!(retention_removed_record_ids(&shared).is_empty());
23575        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23576
23577        drop(shared);
23578        drop(runtime);
23579        std::fs::remove_dir_all(root).unwrap();
23580    }
23581
23582    #[test]
23583    fn session_record_context_export_selects_one_exact_history_identity() {
23584        let candidates = vec![
23585            HistoryCandidateSummary {
23586                id: "candidate-other".to_owned(),
23587                session_id_hint: "provider-other".to_owned(),
23588                modified_at_unix_ms: Some(1),
23589            },
23590            HistoryCandidateSummary {
23591                id: "candidate-exact".to_owned(),
23592                session_id_hint: "provider-exact".to_owned(),
23593                modified_at_unix_ms: Some(2),
23594            },
23595        ];
23596        assert_eq!(
23597            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23598            "candidate-exact",
23599        );
23600    }
23601
23602    #[test]
23603    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23604        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23605            id: id.to_owned(),
23606            session_id_hint: hint.to_owned(),
23607            modified_at_unix_ms: None,
23608        };
23609        let missing = select_history_candidate(
23610            &[candidate("candidate-other", "provider-other")],
23611            Some("provider-exact"),
23612        )
23613        .unwrap_err();
23614        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23615        let ambiguous = select_history_candidate(
23616            &[
23617                candidate("candidate-a", "provider-exact"),
23618                candidate("candidate-b", "provider-exact"),
23619            ],
23620            Some("provider-exact"),
23621        )
23622        .unwrap_err();
23623        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23624    }
23625
23626    /// The PTY-sidecar-observation shape (no fleet provider today, but
23627    /// capability-keyed rather than vendor-locked) never carries a verified
23628    /// `provider_session`; its record-bound export instead falls back to
23629    /// "exactly one discovered candidate" (see
23630    /// `export_context_pack_for_session_record_inner`). This is the same
23631    /// ambiguity refusal as the identity-filtered path, just without an
23632    /// identity to filter by.
23633    #[test]
23634    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23635        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23636            id: id.to_owned(),
23637            session_id_hint: hint.to_owned(),
23638            modified_at_unix_ms: None,
23639        };
23640        assert_eq!(
23641            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23642                .unwrap(),
23643            "candidate-sole",
23644        );
23645        let missing = select_history_candidate(&[], None).unwrap_err();
23646        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23647        let ambiguous = select_history_candidate(
23648            &[
23649                candidate("candidate-a", "sidecar-session-a"),
23650                candidate("candidate-b", "sidecar-session-b"),
23651            ],
23652            None,
23653        )
23654        .unwrap_err();
23655        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23656    }
23657
23658    #[test]
23659    fn session_record_context_export_revalidation_rejects_record_change() {
23660        let session = terminal_address(1);
23661        let identity = ProviderSessionIdentity {
23662            key: ProviderSessionKey::SessionId,
23663            id: "provider-exact".to_owned(),
23664            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23665        };
23666        let mut expected = record("claude", "record-context-export");
23667        expected.state = ManagedSessionState::Live;
23668        expected.provider_session = Some(identity.clone());
23669        expected.active_session = Some(session.clone());
23670        let binding = SessionBinding {
23671            workspace_id: session.workspace_id.clone(),
23672            generation: session.session.generation,
23673            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23674            pending_resume: None,
23675            record_id: Some(expected.record_id.clone()),
23676            managed_worktree_lease_id: None,
23677            environment_profile: None,
23678            bundle: None,
23679            context: None,
23680            materialization_id: None,
23681        };
23682        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23683        assert!(session_record_context_export_binding_is_exact(
23684            &expected,
23685            &expected,
23686            Some(&identity),
23687            &session,
23688            &binding,
23689            &expected.provider,
23690            &current_root,
23691            false,
23692        ));
23693        let mut changed = expected.clone();
23694        changed.updated_at_unix_ms += 1;
23695        assert!(!session_record_context_export_binding_is_exact(
23696            &expected,
23697            &changed,
23698            Some(&identity),
23699            &session,
23700            &binding,
23701            &expected.provider,
23702            &current_root,
23703            false,
23704        ));
23705        // A same-instant timestamp bump alone (state and active_session
23706        // otherwise unchanged) is not the clean-detachment transition
23707        // either, even with the reactive flag on: only the exact Live ->
23708        // Dormant + active_session cleared shape qualifies.
23709        assert!(!session_record_context_export_binding_is_exact(
23710            &expected,
23711            &changed,
23712            Some(&identity),
23713            &session,
23714            &binding,
23715            &expected.provider,
23716            &current_root,
23717            true,
23718        ));
23719        let mut detached = expected.clone();
23720        detached.state = ManagedSessionState::Dormant;
23721        detached.active_session = None;
23722        detached.updated_at_unix_ms += 1;
23723        assert!(!session_record_context_export_binding_is_exact(
23724            &expected,
23725            &detached,
23726            Some(&identity),
23727            &session,
23728            &binding,
23729            &expected.provider,
23730            &current_root,
23731            false,
23732        ));
23733        assert!(session_record_context_export_binding_is_exact(
23734            &expected,
23735            &detached,
23736            Some(&identity),
23737            &session,
23738            &binding,
23739            &expected.provider,
23740            &current_root,
23741            true,
23742        ));
23743    }
23744
23745    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
23746    /// for a record whose provider never carries a verified identity (the
23747    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
23748    /// declares one today): `identity` is `None` throughout, and the
23749    /// exactness check falls back to comparing `provider_session` itself,
23750    /// which must still be `None` on both sides.
23751    #[test]
23752    fn session_record_context_export_binding_is_exact_without_identity() {
23753        let session = terminal_address(1);
23754        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23755        expected.state = ManagedSessionState::Live;
23756        expected.active_session = Some(session.clone());
23757        let binding = SessionBinding {
23758            workspace_id: session.workspace_id.clone(),
23759            generation: session.session.generation,
23760            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23761            pending_resume: None,
23762            record_id: Some(expected.record_id.clone()),
23763            managed_worktree_lease_id: None,
23764            environment_profile: None,
23765            bundle: None,
23766            context: None,
23767            materialization_id: None,
23768        };
23769        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23770        assert!(session_record_context_export_binding_is_exact(
23771            &expected,
23772            &expected,
23773            None,
23774            &session,
23775            &binding,
23776            &expected.provider,
23777            &current_root,
23778            false,
23779        ));
23780        // A record that gained an identity between the initial lookup and
23781        // this recheck no longer matches the `None` this shape expects to
23782        // keep seeing -- treated the same as any other mid-export drift.
23783        let mut drifted = expected.clone();
23784        drifted.provider_session = Some(ProviderSessionIdentity {
23785            key: ProviderSessionKey::SessionId,
23786            id: "unexpected-identity".to_owned(),
23787            transcript_path: None,
23788        });
23789        assert!(!session_record_context_export_binding_is_exact(
23790            &expected,
23791            &drifted,
23792            None,
23793            &session,
23794            &binding,
23795            &expected.provider,
23796            &current_root,
23797            false,
23798        ));
23799    }
23800
23801    #[test]
23802    fn session_record_context_export_preflight_requires_live_usable_source() {
23803        let mut source = record("claude", "record-context-export-preflight");
23804        source.state = ManagedSessionState::Live;
23805        assert!(session_record_context_export_source_is_usable(
23806            &source,
23807            &SessionStatus::Running,
23808            false,
23809        ));
23810        assert!(session_record_context_export_source_is_usable(
23811            &source,
23812            &SessionStatus::Exited { exit_code: Some(0) },
23813            false,
23814        ));
23815
23816        source.state = ManagedSessionState::Dormant;
23817        assert!(!session_record_context_export_source_is_usable(
23818            &source,
23819            &SessionStatus::Running,
23820            false,
23821        ));
23822        // The reactive auto-export-at-exit path (allow_clean_detachment) is
23823        // the only caller that may treat a Dormant, cleanly-exited record as
23824        // still usable — the on-demand path above never does.
23825        assert!(session_record_context_export_source_is_usable(
23826            &source,
23827            &SessionStatus::Exited { exit_code: Some(0) },
23828            true,
23829        ));
23830        assert!(!session_record_context_export_source_is_usable(
23831            &source,
23832            &SessionStatus::Exited { exit_code: Some(0) },
23833            false,
23834        ));
23835        source.state = ManagedSessionState::Live;
23836        assert!(!session_record_context_export_source_is_usable(
23837            &source,
23838            &SessionStatus::Starting,
23839            false,
23840        ));
23841    }
23842
23843    #[test]
23844    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23845        let catalog = active_registry().unwrap();
23846        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23847        let workspace = WorkspaceConfig::new(
23848            WorkspaceId::new("primary").unwrap(),
23849            std::env::current_dir().unwrap(),
23850        )
23851        .unwrap();
23852        let shared = NodeShared::new(
23853            handle,
23854            "fixture-token".to_owned(),
23855            NodeId::new("node-context-export-conflict").unwrap(),
23856            vec![workspace],
23857            vec![agent("claude")],
23858        );
23859        let history = HistorySessionRecord {
23860            session_id: "provider-exact".to_owned(),
23861            title: None,
23862            cwd: None,
23863            model: None,
23864            message_count: 1,
23865            completed_turn_count: None,
23866            total_tokens: 0,
23867            messages: vec![gate4agent_types::HistoryMessageRecord {
23868                role: gate4agent_types::HistoryMessageRole::User,
23869                text: "bounded context".to_owned(),
23870            }],
23871        };
23872        let pack = NodeContextPack::export(
23873            ContextPackLineageReceipt {
23874                source_node_id: shared.node_id.clone(),
23875                source_session: terminal_address(1),
23876                source_provider: agent("claude"),
23877            },
23878            &history,
23879        )
23880        .unwrap();
23881        let context_id = pack.receipt().id.clone();
23882        let session = terminal_address(1);
23883        let identity = ProviderSessionIdentity {
23884            key: ProviderSessionKey::SessionId,
23885            id: history.session_id.clone(),
23886            transcript_path: None,
23887        };
23888        let mut expected = record("claude", "record-context-export-final-conflict");
23889        expected.state = ManagedSessionState::Live;
23890        expected.provider_session = Some(identity.clone());
23891        expected.active_session = Some(session.clone());
23892        let mut changed = expected.clone();
23893        changed.updated_at_unix_ms += 1;
23894        shared.insert_record(changed).unwrap();
23895
23896        let error = shared
23897            .commit_context_pack_for_session_record(
23898                &expected,
23899                Some(&identity),
23900                &session,
23901                "candidate-exact",
23902                &history,
23903                pack,
23904                false,
23905            )
23906            .unwrap_err();
23907
23908        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23909        assert!(shared
23910            .context_catalog
23911            .read()
23912            .unwrap_or_else(|poisoned| poisoned.into_inner())
23913            .get(&context_id)
23914            .is_none());
23915    }
23916
23917    #[tokio::test]
23918    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23919        let root = temporary_workspace_root("session-task-binding");
23920        std::fs::create_dir_all(&root).unwrap();
23921        let state_path = root.join("node-state.json");
23922        let catalog = active_registry().unwrap();
23923        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23924        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23925        let node_id = NodeId::new("node-1").unwrap();
23926        let mut shared = NodeShared::new(
23927            handle,
23928            "fixture-token".to_owned(),
23929            node_id.clone(),
23930            vec![workspace],
23931            vec![agent("claude")],
23932        );
23933        shared.state_path = Some(state_path.clone());
23934        let record = record("claude", "sr-task");
23935        let record_id = record.record_id.clone();
23936        shared.insert_record(record).unwrap();
23937        shared.persist_state().unwrap();
23938        shared
23939            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23940            .unwrap();
23941
23942        let minted = process_request_inner(
23943            &shared,
23944            77,
23945            ClientRole::Operator,
23946            NodeRequest::SetSessionTask {
23947                record_id: record_id.clone(),
23948                expected_revision: 0,
23949                target: SessionTaskTargetV1::New,
23950            },
23951        )
23952        .await
23953        .unwrap();
23954        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23955            panic!("session task mutation returned another response");
23956        };
23957        let minted_binding = minted.task_binding.clone().unwrap();
23958        let minted_task_id = minted_binding.task_id.clone().unwrap();
23959        assert_eq!(minted_binding.revision, 1);
23960        assert!(minted_task_id.as_str().starts_with("task-"));
23961        assert_eq!(minted_task_id.as_str().len(), 29);
23962
23963        let event_count = shared.current_sequence();
23964        let repeated = process_request_inner(
23965            &shared,
23966            77,
23967            ClientRole::Operator,
23968            NodeRequest::SetSessionTask {
23969                record_id: record_id.clone(),
23970                expected_revision: 1,
23971                target: SessionTaskTargetV1::Existing {
23972                    task_id: minted_task_id.clone(),
23973                },
23974            },
23975        )
23976        .await
23977        .unwrap();
23978        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23979        assert_eq!(shared.current_sequence(), event_count);
23980
23981        let stale = process_request_inner(
23982            &shared,
23983            77,
23984            ClientRole::Operator,
23985            NodeRequest::SetSessionTask {
23986                record_id: record_id.clone(),
23987                expected_revision: 0,
23988                target: SessionTaskTargetV1::Existing {
23989                    task_id: minted_task_id,
23990                },
23991            },
23992        )
23993        .await
23994        .unwrap_err();
23995        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23996
23997        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23998        let assigned = process_request_inner(
23999            &shared,
24000            77,
24001            ClientRole::Operator,
24002            NodeRequest::SetSessionTask {
24003                record_id: record_id.clone(),
24004                expected_revision: 1,
24005                target: SessionTaskTargetV1::Existing {
24006                    task_id: assigned_id.clone(),
24007                },
24008            },
24009        )
24010        .await
24011        .unwrap();
24012        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24013            panic!("session task assignment returned another response");
24014        };
24015        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24016        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24017
24018        let cleared = process_request_inner(
24019            &shared,
24020            77,
24021            ClientRole::Operator,
24022            NodeRequest::SetSessionTask {
24023                record_id: record_id.clone(),
24024                expected_revision: 2,
24025                target: SessionTaskTargetV1::Clear,
24026            },
24027        )
24028        .await
24029        .unwrap();
24030        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24031            panic!("session task clear returned another response");
24032        };
24033        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24034        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24035        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24036        assert_eq!(
24037            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24038            cleared.updated_at_unix_ms,
24039        );
24040
24041        let persisted = std::fs::read(&state_path).unwrap();
24042        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24043        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24044        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24045        std::fs::remove_dir_all(root).unwrap();
24046    }
24047
24048    #[test]
24049    fn session_task_capability_strips_snapshots_events_and_replies() {
24050        let mut bound = record("claude", "sr-task-projection");
24051        bound.updated_at_unix_ms = 2;
24052        bound.task_binding = Some(SessionTaskBindingV1 {
24053            revision: 1,
24054            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24055            changed_at_unix_ms: 2,
24056        });
24057        let request = NodeRequest::SetSessionTask {
24058            record_id: bound.record_id.clone(),
24059            expected_revision: 1,
24060            target: SessionTaskTargetV1::Clear,
24061        };
24062        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24063            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24064        }));
24065        assert!(!request_uses_unnegotiated_capability(
24066            &request,
24067            &baseline_capabilities().unwrap(),
24068        ));
24069        assert!(request_uses_unnegotiated_capability(&request, &[]));
24070
24071        let mut snapshot = terminal_test_shared().snapshot();
24072        snapshot.session_records.push(bound.clone());
24073        let mut reply = ResponseEnvelope {
24074            request_id: 1,
24075            result: Ok(NodeResponse::Resync {
24076                event_sequence: 1,
24077                oldest_available_sequence: 1,
24078                snapshot,
24079                events: vec![NodeEventEnvelope {
24080                    sequence: 1,
24081                    event: NodeEvent::SessionRecordUpserted {
24082                        record: bound.clone(),
24083                    },
24084                }],
24085            }),
24086        };
24087        project_response_without_session_task_binding(&mut reply);
24088        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24089            panic!("task projection changed the response kind");
24090        };
24091        assert!(snapshot.session_records[0].task_binding.is_none());
24092        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24093            panic!("task projection changed the event kind");
24094        };
24095        assert!(record.task_binding.is_none());
24096
24097        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24098            sequence: 2,
24099            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24100        });
24101        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24102            panic!("task event projection changed the event kind");
24103        };
24104        assert!(record.task_binding.is_none());
24105
24106        let mut direct = ResponseEnvelope {
24107            request_id: 2,
24108            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24109        };
24110        project_response_without_session_task_binding(&mut direct);
24111        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24112            panic!("task reply projection changed the response kind");
24113        };
24114        assert!(record.task_binding.is_none());
24115    }
24116
24117    #[test]
24118    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24119        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24120        let workspace = WorkspaceConfig::new(
24121            WorkspaceId::new("primary").unwrap(),
24122            std::env::current_dir().unwrap(),
24123        )
24124        .unwrap();
24125        let config = NodeServerConfig::new(
24126            DEFAULT_NODE_ENDPOINT,
24127            "fixture-token",
24128            NodeId::new("node-1").unwrap(),
24129            [workspace],
24130        )
24131        .unwrap();
24132        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24133    }
24134
24135    #[cfg(all(feature = "fixture", windows))]
24136    #[test]
24137    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24138        let root = temporary_workspace_root("provider-bundle-argv-hold");
24139        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24140        std::fs::create_dir_all(&root).unwrap();
24141        std::fs::create_dir_all(&outside).unwrap();
24142        let proof_path = root.join("provider.proof");
24143        let release_signal = root.join("release.signal");
24144        let config = NodeServerConfig::new(
24145            format!(
24146                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24147                std::process::id(),
24148            ),
24149            "fixture-token",
24150            NodeId::new("provider-bundle-hold-node").unwrap(),
24151            [WorkspaceConfig::new(
24152                WorkspaceId::new("primary").unwrap(),
24153                std::env::current_dir().unwrap(),
24154            )
24155            .unwrap()],
24156        )
24157        .unwrap()
24158        .with_spawn_profiles(SpawnProfileRegistry::new([
24159            crate::protocol::SpawnProfileDefaults {
24160                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24161                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24162                    .unwrap(),
24163                provider: agent("kimi"),
24164                mode: SessionMode::Pty,
24165                terminal_size: gate4agent_types::TerminalSize {
24166                    rows: 24,
24167                    columns: 80,
24168                },
24169                prompt: None,
24170                bundle_id: None,
24171                context_id: None,
24172                environment_profile_id: None,
24173            },
24174        ])
24175        .unwrap());
24176        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24177            config,
24178            agent("kimi"),
24179            proof_path.clone(),
24180            release_signal.clone(),
24181        )
24182        .unwrap();
24183        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24184
24185        let spec = NodeServer::provider_bundle_argv_fixture_spec(
24186            agent("kimi"),
24187            proof_path.clone(),
24188            Some(release_signal.clone()),
24189        )
24190        .unwrap();
24191        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24192        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24193        let release_wait = wrapper
24194            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24195            .unwrap();
24196        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24197        assert!(proof_write < release_wait);
24198        assert!(release_wait < fixture_ready);
24199        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24200        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24201        assert_eq!(
24202            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24203            &[
24204                proof_path.to_string_lossy().into_owned(),
24205                release_signal.to_string_lossy().into_owned(),
24206            ],
24207        );
24208
24209        let error = NodeServer::provider_bundle_argv_fixture_spec(
24210            agent("kimi"),
24211            proof_path,
24212            Some(outside.join("release.signal")),
24213        )
24214        .unwrap_err();
24215        assert!(matches!(
24216            error,
24217            NodeServerError::Registry(message)
24218                if message == "provider bundle argv release path must share the proof parent"
24219        ));
24220        drop(server);
24221        std::fs::remove_dir_all(root).unwrap();
24222        std::fs::remove_dir_all(outside).unwrap();
24223    }
24224
24225    #[cfg(feature = "fixture")]
24226    #[test]
24227    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24228        let root = temporary_workspace_root("context-fixture-registry");
24229        std::fs::create_dir_all(&root).unwrap();
24230        let proof_path = root.join("context.proof");
24231        let config = NodeServerConfig::new(
24232            format!(
24233                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24234                std::process::id(),
24235            ),
24236            "fixture-token",
24237            NodeId::new("context-fixture-node").unwrap(),
24238            [WorkspaceConfig::new(
24239                WorkspaceId::new("primary").unwrap(),
24240                std::env::current_dir().unwrap(),
24241            )
24242            .unwrap()],
24243        )
24244        .unwrap();
24245        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24246        let mut enabled = server
24247            .shared
24248            .enabled_providers
24249            .iter()
24250            .map(AgentId::as_str)
24251            .collect::<Vec<_>>();
24252        enabled.sort_unstable();
24253        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24254        for provider in &server.shared.enabled_providers {
24255            assert!(server
24256                .shared
24257                .provider_adapter_contracts
24258                .iter()
24259                .any(|contract| {
24260                    &contract.provider == provider
24261                        && contract.family == AdapterFamily::History
24262                }));
24263        }
24264        drop(server);
24265        std::fs::remove_dir_all(root).unwrap();
24266    }
24267
24268    #[cfg(feature = "fixture")]
24269    #[test]
24270    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24271        let root = temporary_workspace_root("monitoring-context-fixture");
24272        std::fs::create_dir_all(&root).unwrap();
24273        let proof_path = root.join("context.proof");
24274        let standard = NodeServer::context_pack_fixture_catalog(
24275            proof_path.clone(),
24276            None,
24277            false,
24278        ).unwrap();
24279        let monitoring = NodeServer::context_pack_fixture_catalog(
24280            proof_path.clone(),
24281            None,
24282            true,
24283        ).unwrap();
24284        let claude = AgentId::new("claude").unwrap();
24285        let standard_claude = standard.get(&claude).unwrap();
24286        let monitoring_claude = monitoring.get(&claude).unwrap();
24287        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
24288        // standard nor the monitoring fixture declares a hook adapter
24289        // anymore.
24290        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24291        assert_eq!(
24292            monitoring_claude.capabilities.adapters.history,
24293            standard_claude.capabilities.adapters.history,
24294        );
24295        assert_ne!(monitoring_claude.launch, standard_claude.launch);
24296        let codex = AgentId::new("codex").unwrap();
24297        let standard_codex = standard.get(&codex).unwrap();
24298        let monitoring_codex = monitoring.get(&codex).unwrap();
24299        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24300        assert_eq!(
24301            monitoring_codex.capabilities.adapters.history,
24302            standard_codex.capabilities.adapters.history,
24303        );
24304        assert_ne!(monitoring_codex.launch, standard_codex.launch);
24305        let codex_validation = monitoring_codex.launch.fixed_args.iter()
24306            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24307            .unwrap();
24308        for required in [
24309            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24310            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24311            "@('role', 'text')",
24312            "g4a-private-provider-session-canary",
24313            "private-provider-login-identity",
24314            "$rawProof",
24315            "$env:GATE4AGENT_HOOK_URL",
24316            "g4a-private-provider-session-canary",
24317        ] {
24318            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24319        }
24320        for provider in ["grok", "kimi"] {
24321            let provider = AgentId::new(provider).unwrap();
24322            assert_eq!(monitoring.get(&provider), standard.get(&provider));
24323        }
24324
24325        let config = NodeServerConfig::new(
24326            format!(
24327                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24328                std::process::id(),
24329            ),
24330            "fixture-token",
24331            NodeId::new("monitoring-context-fixture-node").unwrap(),
24332            [WorkspaceConfig::new(
24333                WorkspaceId::new("primary").unwrap(),
24334                std::env::current_dir().unwrap(),
24335            )
24336            .unwrap()],
24337        )
24338        .unwrap();
24339        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24340        assert!(server.shared.fixture_semantic_hook_policy);
24341        drop(server);
24342        std::fs::remove_dir_all(root).unwrap();
24343    }
24344
24345    #[cfg(feature = "fixture")]
24346    #[test]
24347    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24348        let root = temporary_workspace_root("kimi-context-only-fixture");
24349        std::fs::create_dir_all(&root).unwrap();
24350        let proof_path = root.join("context.proof");
24351        let standard = NodeServer::context_pack_fixture_catalog(
24352            proof_path.clone(),
24353            None,
24354            false,
24355        ).unwrap();
24356        let kimi = AgentId::new("kimi").unwrap();
24357        let context_only = NodeServer::context_pack_fixture_catalog(
24358            proof_path,
24359            Some(&kimi),
24360            false,
24361        )
24362        .unwrap();
24363
24364        let codex = AgentId::new("codex").unwrap();
24365        assert_eq!(
24366            standard.get(&codex).unwrap().launch.fixed_args,
24367            context_only.get(&codex).unwrap().launch.fixed_args,
24368        );
24369        let script = context_only
24370            .get(&kimi)
24371            .unwrap()
24372            .launch
24373            .fixed_args
24374            .iter()
24375            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24376            .expect("Kimi context-only validation script is missing");
24377        for required in [
24378            "$bundleArgs.Count -ne 0",
24379            "GATE4AGENT_CONTEXT_ROOT",
24380            "context-pack.json",
24381            "$entries.Count -ne 1",
24382            "g4a-context-pack-v1",
24383            "$document.PSObject.Properties['cwd']",
24384            "'claude', 'codex', 'grok', 'kimi'",
24385            "$hasUser",
24386            "$hasAssistant",
24387            "SHA256",
24388            "context-only",
24389        ] {
24390            assert!(script.contains(required), "missing validation: {required}");
24391        }
24392        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24393            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24394        }
24395        assert!(!standard
24396            .get(&kimi)
24397            .unwrap()
24398            .launch
24399            .fixed_args
24400            .iter()
24401            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24402
24403        std::fs::remove_dir_all(root).unwrap();
24404    }
24405
24406    #[cfg(all(feature = "fixture", windows))]
24407    #[tokio::test]
24408    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24409        let root = temporary_workspace_root("kimi-context-only-spawn");
24410        let workspace_root = root.join("workspace");
24411        std::fs::create_dir_all(&workspace_root).unwrap();
24412        let materialization_root = root.join("private-materializations");
24413        let proof_path = root.join("context.proof");
24414        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24415        let workspace_id = WorkspaceId::new("primary").unwrap();
24416        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24417        let endpoint = format!(
24418            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24419            std::process::id(),
24420            unix_time_ms(),
24421        );
24422        let spawn_profiles = SpawnProfileRegistry::new([
24423            crate::protocol::SpawnProfileDefaults {
24424                profile_id: profile_id.clone(),
24425                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24426                provider: agent("kimi"),
24427                mode: SessionMode::Pty,
24428                terminal_size: gate4agent_types::TerminalSize {
24429                    rows: 30,
24430                    columns: 120,
24431                },
24432                prompt: None,
24433                bundle_id: None,
24434                context_id: None,
24435                environment_profile_id: None,
24436            },
24437        ])
24438        .unwrap();
24439        let mut config = NodeServerConfig::new(
24440            &endpoint,
24441            "fixture-token",
24442            node_id.clone(),
24443            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24444        )
24445        .unwrap()
24446        .with_spawn_profiles(spawn_profiles)
24447        .with_session_environment_materialization(
24448            materialization_root,
24449            Arc::new(FixtureSecretResolver {
24450                deny: Arc::new(AtomicBool::new(false)),
24451            }),
24452        )
24453        .unwrap();
24454        config.fixture_raw_pty_runtime = true;
24455        let server = NodeServer::new_context_only_proof_fixture(
24456            config,
24457            agent("kimi"),
24458            proof_path.clone(),
24459        )
24460        .unwrap();
24461        let shared = Arc::clone(&server.shared);
24462        let shutdown = server.shutdown_handle();
24463
24464        let history = HistorySessionRecord {
24465            session_id: "context-only-source-history".to_owned(),
24466            title: None,
24467            cwd: None,
24468            model: None,
24469            message_count: 2,
24470            completed_turn_count: None,
24471            total_tokens: 0,
24472            messages: vec![
24473                gate4agent_types::HistoryMessageRecord {
24474                    role: gate4agent_types::HistoryMessageRole::User,
24475                    text: "continue the bounded parent run through native C2".to_owned(),
24476                },
24477                gate4agent_types::HistoryMessageRecord {
24478                    role: gate4agent_types::HistoryMessageRole::Assistant,
24479                    text: "the exact parent context is ready for the child run".to_owned(),
24480                },
24481            ],
24482        };
24483        let pack = NodeContextPack::export(
24484            ContextPackLineageReceipt {
24485                source_node_id: node_id.clone(),
24486                source_session: SessionAddress {
24487                    workspace_id: workspace_id.clone(),
24488                    session: SessionKey {
24489                        instance_id: AgentInstanceId(71),
24490                        generation: SessionGeneration(1),
24491                    },
24492                },
24493                source_provider: agent("codex"),
24494            },
24495            &history,
24496        )
24497        .unwrap();
24498        let expected_context_hash = digest(&SHA256, pack.bytes())
24499            .as_ref()
24500            .iter()
24501            .map(|byte| format!("{byte:02x}"))
24502            .collect::<String>();
24503        let context = shared
24504            .context_catalog
24505            .write()
24506            .unwrap_or_else(|poisoned| poisoned.into_inner())
24507            .insert(pack)
24508            .unwrap();
24509        let spec = SpawnSpec {
24510            target: crate::protocol::SpawnTarget {
24511                node_id,
24512                workspace_id: workspace_id.clone(),
24513                worktree_id: None,
24514            },
24515            profile_id,
24516            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24517                "target-kimi-r1",
24518            ).unwrap(),
24519            overrides: crate::protocol::SpawnOverrides {
24520                context_id: crate::protocol::SpawnOverride::Set {
24521                    value: context.id.clone(),
24522                },
24523                ..crate::protocol::SpawnOverrides::default()
24524            },
24525            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24526            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24527            required_capabilities: SpawnRequiredCapabilities::default(),
24528        };
24529        let resolved = match shared.resolve_spawn_spec(&spec) {
24530            Ok(resolved) => resolved,
24531            Err(error) => panic!(
24532                "Kimi context-only resolve rejected: exact_code={:?} message={}",
24533                error.code,
24534                error.message,
24535            ),
24536        };
24537        let resolved_context = match shared.resolve_context(&resolved) {
24538            Ok(Some(resolved_context)) => resolved_context,
24539            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24540            Err(error) => panic!(
24541                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24542                error.code,
24543                error.message,
24544            ),
24545        };
24546        assert_eq!(resolved_context, context);
24547        let runtime_policy = match shared
24548            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24549            .await
24550        {
24551            Ok(runtime_policy) => runtime_policy,
24552            Err(error) => panic!(
24553                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24554                error.code,
24555                error.message,
24556            ),
24557        };
24558        assert!(runtime_policy.raw_pty_lifecycle);
24559
24560        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24561        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24562            agent("kimi"),
24563            TransportKind::Pty,
24564            vec![EnvMutation {
24565                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24566                value: Some(OsString::from("must-remain-rejected")),
24567            }],
24568        )
24569        .unwrap();
24570        assert_eq!(
24571            launch_control
24572                .install_native_launch_environment_overlay(
24573                    AgentInstanceId(72),
24574                    generic_overlay,
24575                )
24576                .unwrap_err(),
24577            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24578        );
24579
24580        let diagnostic_address = SessionAddress {
24581            workspace_id: workspace_id.clone(),
24582            session: SessionKey {
24583                instance_id: AgentInstanceId(73),
24584                generation: SessionGeneration(1),
24585            },
24586        };
24587        let (diagnostic_overlay, diagnostic_materialization) = match shared
24588            .prepare_session_materialization(
24589                &diagnostic_address,
24590                &agent("kimi"),
24591                SessionMode::Pty,
24592                None,
24593                None,
24594                Some(&resolved_context),
24595                None,
24596            )
24597        {
24598            Ok(Some(prepared)) => prepared,
24599            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24600            Err(error) => panic!(
24601                "Kimi context-only materialization rejected: exact_code={:?} message={}",
24602                error.code,
24603                error.message,
24604            ),
24605        };
24606        let diagnostic_overlay = diagnostic_overlay
24607            .expect("Kimi context-only materialization omitted its environment overlay");
24608        assert!(matches!(
24609            &diagnostic_overlay,
24610            PreparedNativeLaunchOverlay::Environment(_),
24611        ));
24612        match shared.install_prepared_launch_overlay(
24613            diagnostic_address.session.instance_id,
24614            diagnostic_overlay,
24615        ) {
24616            Ok(None) => {}
24617            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24618            Err(error) => panic!(
24619                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24620                error.code,
24621                error.message,
24622            ),
24623        }
24624        assert!(launch_control.clear_native_instance_launch_overlay(
24625            diagnostic_address.session.instance_id,
24626        ));
24627        drop(diagnostic_materialization);
24628        assert!(shared
24629            .materializations
24630            .lock()
24631            .unwrap_or_else(|poisoned| poisoned.into_inner())
24632            .is_empty());
24633
24634        let node_task = tokio::spawn(server.run());
24635        let ready_client = timeout(Duration::from_secs(5), async {
24636            loop {
24637                match gate4agent_node_wire::LocalNodeClient::connect(
24638                    &endpoint,
24639                    &shared.node_id,
24640                    ClientRole::Observer,
24641                    "fixture-token",
24642                )
24643                .await
24644                {
24645                    Ok(client) => break client,
24646                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24647                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24648                }
24649            }
24650        })
24651        .await
24652        .expect("Kimi context-only Node did not become ready");
24653        assert_eq!(
24654            ready_client
24655                .hello()
24656                .snapshot
24657                .provider_runtime_statuses
24658                .iter()
24659                .find(|status| status.provider().as_str() == "kimi")
24660                .expect("Kimi runtime status is missing")
24661                .mode(),
24662            crate::protocol::ProviderRuntimeMode::RawPassthrough,
24663        );
24664        drop(ready_client);
24665
24666        let receipt = match shared.spawn_from_spec(spec).await {
24667            Ok(receipt) => receipt,
24668            Err(error) => panic!(
24669                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24670                error.code,
24671                error.message,
24672            ),
24673        };
24674        assert_eq!(receipt.provider, agent("kimi"));
24675        assert_eq!(receipt.context.as_ref(), Some(&context));
24676        assert!(receipt.context_binding_is_valid());
24677        timeout(Duration::from_secs(5), async {
24678            while !proof_path.is_file() {
24679                sleep(Duration::from_millis(10)).await;
24680            }
24681        })
24682        .await
24683        .expect("Kimi context-only fixture did not write its proof");
24684
24685        let proof = std::fs::read_to_string(&proof_path).unwrap();
24686        let lines = proof.lines().collect::<Vec<_>>();
24687        assert_eq!(lines.len(), 7);
24688        assert_eq!(lines[0], "context-only");
24689        assert!(Path::new(lines[1]).is_absolute());
24690        assert_ne!(
24691            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24692            std::fs::canonicalize(&workspace_root).unwrap(),
24693        );
24694        assert_eq!(
24695            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24696            std::fs::canonicalize(&workspace_root).unwrap(),
24697        );
24698        assert_eq!(lines[3], expected_context_hash);
24699        assert_eq!(lines[4], "g4a-context-pack-v1");
24700        assert_eq!(lines[5], "codex");
24701        assert_eq!(lines[6], "2");
24702        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24703            session.instance_id == receipt.session.session.instance_id
24704                && session.agent_id.as_str() == "kimi"
24705        }));
24706
24707        shutdown.request_shutdown().await.unwrap();
24708        timeout(Duration::from_secs(5), node_task)
24709            .await
24710            .unwrap()
24711            .unwrap()
24712            .unwrap();
24713        drop(shutdown);
24714        drop(shared);
24715        std::fs::remove_dir_all(root).unwrap();
24716    }
24717
24718    #[cfg(feature = "fixture")]
24719    #[test]
24720    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24721        let root = temporary_workspace_root("clean-exit-constructor");
24722        std::fs::create_dir_all(&root).unwrap();
24723        let config = construction_test_config("clean-exit-constructor")
24724            .with_state_path(root.join("node-state.json"))
24725            .unwrap();
24726        let server = NodeServer::new_clean_exit_fixture(
24727            config,
24728            root.clone(),
24729            root.join("started.marker"),
24730            root.join("release.signal"),
24731        )
24732        .unwrap();
24733        assert_eq!(
24734            server
24735                .shared
24736                .enabled_providers
24737                .iter()
24738                .map(AgentId::as_str)
24739                .collect::<Vec<_>>(),
24740            ["claude"],
24741        );
24742        drop(server);
24743        std::fs::remove_dir_all(root).unwrap();
24744    }
24745
24746    #[test]
24747    fn production_registry_provider_contract_manifest_is_exact() {
24748        let registry = active_registry().unwrap();
24749        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24750        assert_eq!(
24751            providers
24752                .iter()
24753                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24754                .collect::<Vec<_>>(),
24755            vec![
24756                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24757                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24758                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24759                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24760            ]
24761        );
24762        assert_eq!(
24763            adapters
24764                .iter()
24765                .map(|contract| (
24766                    contract.provider.as_str(),
24767                    contract.family,
24768                    contract.adapter_id.as_str(),
24769                    contract.revision.as_str(),
24770                ))
24771                .collect::<Vec<_>>(),
24772            vec![
24773                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24774                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24775                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24776                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24777                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24778                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24779                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24780                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24781                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24782                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24783                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24784                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24785                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24786                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24787                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24788                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24789                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24790                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24791                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24792                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24793                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24794                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24795                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24796            ]
24797        );
24798    }
24799
24800    fn construction_test_config(name: &str) -> NodeServerConfig {
24801        let workspace = WorkspaceConfig::new(
24802            WorkspaceId::new("primary").unwrap(),
24803            std::env::current_dir().unwrap(),
24804        )
24805        .unwrap();
24806        NodeServerConfig::new(
24807            format!(r"\\.\pipe\gate4agent-{name}"),
24808            "fixture-token",
24809            NodeId::new(name).unwrap(),
24810            [workspace],
24811        )
24812        .unwrap()
24813    }
24814
24815    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24816        let adapter = AdapterBinding::new(
24817            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24818            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24819            gate4agent_types::AdapterVerification::SyntheticFixture,
24820        )
24821        .unwrap();
24822        let agent_ids = ["claude", "codex", "kimi"]
24823            .into_iter()
24824            .map(|agent_id| AgentId::new(agent_id).unwrap())
24825            .collect::<Vec<_>>();
24826        let adapters = gate4agent_catalog::AdapterRegistry::new(
24827            [
24828                AdapterFamily::PtySemantic,
24829                AdapterFamily::Pipe,
24830                AdapterFamily::OneShot,
24831                AdapterFamily::Acp,
24832                AdapterFamily::Hook,
24833                AdapterFamily::ManagedHook,
24834                AdapterFamily::History,
24835                AdapterFamily::Resume,
24836                AdapterFamily::SessionOptions,
24837                AdapterFamily::CapabilityProbe,
24838            ]
24839            .into_iter()
24840            .map(|family| gate4agent_catalog::AdapterDescriptor {
24841                family,
24842                binding: adapter.clone(),
24843                agents: agent_ids.clone(),
24844            }),
24845        )
24846        .unwrap();
24847        let specs = agent_ids
24848            .iter()
24849            .map(|agent_id| {
24850                let agent_id = agent_id.as_str();
24851                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24852                spec.revision =
24853                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24854                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24855                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24856                    adapter: adapter.clone(),
24857                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24858                    launch_override: None,
24859                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24860                });
24861                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24862                    adapter: adapter.clone(),
24863                    launch_override: None,
24864                });
24865                spec.capabilities.adapters.hook = Some(adapter.clone());
24866                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24867                spec.capabilities.adapters.one_shot = Some(adapter.clone());
24868                spec.capabilities.adapters.history = Some(adapter.clone());
24869                spec.capabilities.adapters.resume = Some(adapter.clone());
24870                spec.capabilities.adapters.session_options = Some(adapter.clone());
24871                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24872                spec
24873            })
24874            .collect::<Vec<_>>();
24875        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24876    }
24877
24878    /// The real fleet's manifest is accepted by a real server construction.
24879    ///
24880    /// The count is asserted against `active_registry` itself rather than a
24881    /// literal, so this stays a test that construction succeeds for whatever
24882    /// the fleet is -- which is the property worth guarding -- instead of a
24883    /// second place the fleet size is written down and has to be kept in
24884    /// step with the first.
24885    #[test]
24886    fn node_server_construction_accepts_the_production_manifest() {
24887        let registry = active_registry().unwrap();
24888        let (providers, _) = provider_contract_manifest(&registry).unwrap();
24889        assert_eq!(providers.len(), registry.iter().count());
24890        assert!(!providers.is_empty());
24891        let server = NodeServer::new_with_registry(
24892            construction_test_config("manifest-production-bound"),
24893            registry,
24894        )
24895        .unwrap();
24896        drop(server);
24897    }
24898
24899    #[test]
24900    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24901        let registry = maximum_size_provider_manifest_registry();
24902        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24903        assert_eq!(providers.len(), 3);
24904        assert_eq!(adapters.len(), 30);
24905        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24906        let error = match NodeServer::new_with_registry(
24907            construction_test_config("manifest-handshake-overflow"),
24908            registry,
24909        ) {
24910            Ok(_) => panic!("oversized negotiated manifest was accepted"),
24911            Err(error) => error,
24912        };
24913        assert!(matches!(
24914            error,
24915            NodeServerError::ProviderContractManifest(message)
24916                if message.contains("authentication binding length")
24917                    && message.contains("8192-byte limit")
24918        ));
24919    }
24920
24921    #[test]
24922    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24923        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24924        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24925        let registry = AgentRegistry::new([spec]).unwrap();
24926        assert!(matches!(
24927            provider_contract_manifest(&registry),
24928            Err(NodeServerError::ProviderContractManifest(message))
24929                if message.contains("exceeds the 128-byte limit")
24930        ));
24931    }
24932
24933    #[test]
24934    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24935        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24936        spec.revision = "invalid revision".to_owned();
24937        let registry = AgentRegistry::new([spec]).unwrap();
24938        assert!(matches!(
24939            provider_contract_manifest(&registry),
24940            Err(NodeServerError::ProviderContractManifest(message))
24941                if message.contains("bounded lowercase ASCII")
24942        ));
24943    }
24944
24945    #[test]
24946    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24947        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24948        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24949        let conflicting_binding = builtin_registry()
24950            .get_by_id("codex")
24951            .unwrap()
24952            .capabilities
24953            .adapters
24954            .one_shot
24955            .clone()
24956            .unwrap();
24957        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24958        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24959        pipe.adapter = transport_binding;
24960        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24961        let registry = AgentRegistry::new([spec]).unwrap();
24962        assert!(matches!(
24963            provider_contract_manifest(&registry),
24964            Err(NodeServerError::ProviderContractManifest(message))
24965                if message.contains("conflicting OneShot adapter bindings")
24966        ));
24967    }
24968
24969    #[test]
24970    fn public_node_failures_expose_only_fixed_categories() {
24971        let provider = failure(
24972            NodeFailureCode::BackendOperationFailed,
24973            r"provider bearer-secret failed at C:\private\session.jsonl",
24974        );
24975        assert_eq!(provider.message, "backend-operation-failed");
24976        assert!(!provider.message.contains("bearer-secret"));
24977        assert!(!provider.message.contains(r"C:\private"));
24978        let persistence = persistence_failure(io::Error::new(
24979            io::ErrorKind::Other,
24980            r"state write failed at C:\private\state-v1.json",
24981        ));
24982        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24983        assert!(!persistence.message.contains(r"C:\private"));
24984
24985        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24986            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24987            assert_eq!(
24988                generic.to_string(),
24989                "node durable state failed: durable-state-load-failed",
24990            );
24991        }
24992    }
24993
24994    #[test]
24995    fn terminal_byte_sequences_are_bounded_before_dispatch() {
24996        let sequence = b"\x1b[1;5D".to_vec();
24997        assert_eq!(
24998            terminal_bytes_action(sequence.clone()).unwrap(),
24999            InputAction::TerminalBytes(sequence),
25000        );
25001        assert_eq!(
25002            terminal_bytes_action(Vec::new()).unwrap_err().code,
25003            NodeFailureCode::InvalidRequest,
25004        );
25005        assert_eq!(
25006            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25007                .unwrap_err()
25008                .code,
25009            NodeFailureCode::InvalidRequest,
25010        );
25011    }
25012
25013    #[test]
25014    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25015        let root = temporary_workspace_root("exclusive-state-owner");
25016        std::fs::create_dir_all(&root).unwrap();
25017        let workspace = WorkspaceConfig::new(
25018            WorkspaceId::new("primary").unwrap(),
25019            &root,
25020        )
25021        .unwrap();
25022        let state_path = root.join("state-v1.json");
25023        let endpoint = format!(
25024            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25025            std::process::id(),
25026            std::time::SystemTime::now()
25027                .duration_since(std::time::UNIX_EPOCH)
25028                .unwrap()
25029                .as_nanos(),
25030        );
25031        let config = NodeServerConfig::new(
25032            endpoint,
25033            "fixture-token",
25034            NodeId::new("node-1").unwrap(),
25035            [workspace],
25036        )
25037        .unwrap()
25038        .with_state_path(&state_path)
25039        .unwrap();
25040
25041        let first = NodeServer::new(config.clone()).unwrap();
25042        let error = match NodeServer::new(config.clone()) {
25043            Ok(_) => panic!("a second node server acquired the same durable state path"),
25044            Err(error) => error,
25045        };
25046        assert!(matches!(
25047            &error,
25048            NodeServerError::DurableState(error)
25049                if error.kind() == io::ErrorKind::WouldBlock
25050        ));
25051        assert_eq!(
25052            error.to_string(),
25053            "node durable state failed: durable-state-lock-failed",
25054        );
25055        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25056        drop(first);
25057        let second = NodeServer::new(config).unwrap();
25058        drop(second);
25059        std::fs::remove_dir_all(root).unwrap();
25060    }
25061
25062    #[test]
25063    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25064        let catalog = active_registry().unwrap();
25065        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25066        let workspace_id = WorkspaceId::new("primary").unwrap();
25067        let workspace = WorkspaceConfig::new(
25068            workspace_id.clone(),
25069            std::env::current_dir().unwrap(),
25070        )
25071        .unwrap();
25072        let shared = NodeShared::new(
25073            handle,
25074            "fixture-token".to_owned(),
25075            NodeId::new("node-1").unwrap(),
25076            vec![workspace.clone()],
25077            vec![agent("claude"), agent("codex"), agent("kimi")],
25078        );
25079        let instance_id = AgentInstanceId(17);
25080        let generation = SessionGeneration(3);
25081        assert!(shared.address_for(instance_id, generation).is_none());
25082        shared.bind_session(instance_id, workspace_id.clone(), generation);
25083        let address = SessionAddress {
25084            workspace_id,
25085            session: SessionKey { instance_id, generation },
25086        };
25087        assert_eq!(
25088            shared.workspace_root(&address.workspace_id).unwrap(),
25089            workspace.canonical_root,
25090        );
25091        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25092        shared.remove_binding(&address);
25093        assert!(shared.address_for(instance_id, generation).is_none());
25094    }
25095
25096    #[test]
25097    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25098        let address = SessionAddress {
25099            workspace_id: WorkspaceId::new("primary").unwrap(),
25100            session: SessionKey {
25101                instance_id: AgentInstanceId(7),
25102                generation: SessionGeneration(2),
25103            },
25104        };
25105        let record_id = SessionRecordId::new("record-a").unwrap();
25106        let size = gate4agent_types::TerminalSize {
25107            rows: 24,
25108            columns: 80,
25109        };
25110        let address_requests = [
25111            NodeRequest::Resume {
25112                session: address.clone(),
25113                terminal_size: size,
25114                initial_prompt: None,
25115            },
25116            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25117            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25118            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25119            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25120            NodeRequest::TerminalControl {
25121                session: address.clone(),
25122                control: TerminalControl::Enter,
25123            },
25124            NodeRequest::Resize { session: address.clone(), size },
25125            NodeRequest::Interrupt { session: address.clone() },
25126            NodeRequest::Stop { session: address.clone(), force: false },
25127            NodeRequest::Remove { session: address },
25128        ];
25129        for request in &address_requests {
25130            assert!(request_requires_open_provider_ids_with(
25131                request,
25132                |_| Some(agent("third-party-agent")),
25133                |_| Some(agent("claude")),
25134            ));
25135            assert!(!request_requires_open_provider_ids_with(
25136                request,
25137                |_| Some(agent("claude")),
25138                |_| Some(agent("claude")),
25139            ));
25140            assert!(request_requires_open_provider_ids_with(
25141                request,
25142                |_| None,
25143                |_| Some(agent("claude")),
25144            ));
25145        }
25146
25147        let record_requests = [
25148            NodeRequest::RenameSessionRecord {
25149                record_id: record_id.clone(),
25150                display_name: "renamed".to_owned(),
25151            },
25152            NodeRequest::ResumeSessionRecord {
25153                record_id: record_id.clone(),
25154                terminal_size: size,
25155                initial_prompt: None,
25156            },
25157            NodeRequest::ForgetSessionRecord { record_id },
25158        ];
25159        for request in &record_requests {
25160            assert!(request_requires_open_provider_ids_with(
25161                request,
25162                |_| Some(agent("claude")),
25163                |_| Some(agent("grok")),
25164            ));
25165            assert!(!request_requires_open_provider_ids_with(
25166                request,
25167                |_| Some(agent("claude")),
25168                |_| Some(agent("kimi")),
25169            ));
25170            assert!(request_requires_open_provider_ids_with(
25171                request,
25172                |_| Some(agent("claude")),
25173                |_| None,
25174            ));
25175        }
25176
25177        assert!(request_requires_open_provider_ids_with(
25178            &NodeRequest::Spawn {
25179                workspace_id: WorkspaceId::new("primary").unwrap(),
25180                provider: agent("third-party-agent"),
25181                mode: SessionMode::Pty,
25182                terminal_size: size,
25183                initial_prompt: None,
25184            },
25185            |_| None,
25186            |_| None,
25187        ));
25188        assert!(!request_requires_open_provider_ids_with(
25189            &NodeRequest::Snapshot,
25190            |_| None,
25191            |_| None,
25192        ));
25193        assert!(request_requires_open_provider_ids_with(
25194            &NodeRequest::ForgetContextPack {
25195                context_id: SpawnContextId::new("ctx-forget").unwrap(),
25196            },
25197            |_| None,
25198            |_| None,
25199        ));
25200    }
25201
25202    #[test]
25203    fn legacy_projection_filters_open_record_replies_and_event_history() {
25204        let catalog = active_registry().unwrap();
25205        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25206        let workspace = WorkspaceConfig::new(
25207            WorkspaceId::new("primary").unwrap(),
25208            std::env::current_dir().unwrap(),
25209        )
25210        .unwrap();
25211        let shared = NodeShared::new(
25212            handle,
25213            "fixture-token".to_owned(),
25214            NodeId::new("node-1").unwrap(),
25215            vec![workspace],
25216            vec![agent("claude"), agent("grok")],
25217        );
25218        let legacy = record("claude", "legacy-record");
25219        let open = record("grok", "open-record");
25220        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25221            record: legacy.clone(),
25222        });
25223        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25224            record: open.clone(),
25225        });
25226        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25227            record_id: legacy.record_id.clone(),
25228        });
25229        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25230            record_id: open.record_id.clone(),
25231        });
25232        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25233        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25234        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25235        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25236
25237        let mut record_reply = ResponseEnvelope {
25238            request_id: 1,
25239            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25240        };
25241        project_response_legacy_provider_ids(&shared, &mut record_reply);
25242        assert!(matches!(
25243            record_reply.result,
25244            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25245        ));
25246
25247        let mut resync = ResponseEnvelope {
25248            request_id: 2,
25249            result: Ok(NodeResponse::Resync {
25250                event_sequence: open_removed.sequence,
25251                oldest_available_sequence: 1,
25252                snapshot: shared.snapshot(),
25253                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25254            }),
25255        };
25256        project_response_legacy_provider_ids(&shared, &mut resync);
25257        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25258            panic!("legacy projection changed the resync response kind");
25259        };
25260        assert!(snapshot.session_records.iter().all(|item| {
25261            provider_id_is_legacy(&item.provider)
25262        }));
25263        assert_eq!(events.len(), 2);
25264        assert!(events.iter().all(|event| matches!(
25265            &event.event,
25266            NodeEvent::SessionRecordUpserted { record }
25267                if provider_id_is_legacy(&record.provider)
25268        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25269    }
25270
25271    #[test]
25272    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25273        let catalog = active_registry().unwrap();
25274        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25275        let workspace_id = WorkspaceId::new("primary").unwrap();
25276        let workspace = WorkspaceConfig::new(
25277            workspace_id.clone(),
25278            std::env::current_dir().unwrap(),
25279        )
25280        .unwrap();
25281        let shared = NodeShared::new(
25282            handle,
25283            "fixture-token".to_owned(),
25284            NodeId::new("node-1").unwrap(),
25285            vec![workspace],
25286            vec![agent("claude")],
25287        );
25288        for index in 0..CONTROL_SESSIONS_MAX {
25289            shared.bind_session(
25290                AgentInstanceId((index + 1) as u64),
25291                workspace_id.clone(),
25292                SessionGeneration(1),
25293            );
25294        }
25295        let error = shared.ensure_binding_capacity().unwrap_err();
25296        assert_eq!(error.code, NodeFailureCode::BackendBusy);
25297    }
25298
25299    #[tokio::test]
25300    async fn provider_spawn_admission_rejects_before_session_mutation() {
25301        let catalog = active_registry().unwrap();
25302        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25303        let workspace = WorkspaceConfig::new(
25304            WorkspaceId::new("primary").unwrap(),
25305            std::env::current_dir().unwrap(),
25306        )
25307        .unwrap();
25308        let mut shared = NodeShared::new(
25309            handle,
25310            "fixture-token".to_owned(),
25311            NodeId::new("node-provider-admission").unwrap(),
25312            vec![workspace],
25313            vec![agent("claude")],
25314        );
25315        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25316            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25317        ])
25318        .unwrap();
25319        let terminal_size = gate4agent_types::TerminalSize {
25320            rows: 24,
25321            columns: 80,
25322        };
25323
25324        let unavailable = shared
25325            .spawn_session(
25326                WorkspaceId::new("primary").unwrap(),
25327                agent("claude"),
25328                SessionMode::Pty,
25329                terminal_size,
25330                None,
25331            )
25332            .await
25333            .unwrap_err();
25334        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25335        assert_eq!(unavailable.message, "backend-operation-failed");
25336
25337        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25338            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25339                agent("claude"),
25340                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25341            ),
25342        ])
25343        .unwrap();
25344        let semantic = shared
25345            .spawn_session(
25346                WorkspaceId::new("primary").unwrap(),
25347                agent("claude"),
25348                SessionMode::Inline,
25349                terminal_size,
25350                None,
25351            )
25352            .await
25353            .unwrap_err();
25354        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25355        assert_eq!(semantic.message, "unsupported-capability");
25356
25357        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25358        assert!(shared
25359            .session_bindings
25360            .lock()
25361            .unwrap_or_else(|poisoned| poisoned.into_inner())
25362            .is_empty());
25363        assert!(shared
25364            .session_records
25365            .lock()
25366            .unwrap_or_else(|poisoned| poisoned.into_inner())
25367            .records
25368            .is_empty());
25369        assert!(shared.handle.snapshot().sessions.is_empty());
25370    }
25371
25372    #[tokio::test]
25373    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25374        let catalog = active_registry().unwrap();
25375        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25376        let workspace_id = WorkspaceId::new("primary").unwrap();
25377        let workspace = WorkspaceConfig::new(
25378            workspace_id.clone(),
25379            std::env::current_dir().unwrap(),
25380        )
25381        .unwrap();
25382        let shared = NodeShared::new(
25383            handle,
25384            "fixture-token".to_owned(),
25385            NodeId::new("node-raw-spawn-record").unwrap(),
25386            vec![workspace],
25387            vec![agent("claude")],
25388        );
25389        let address = SessionAddress {
25390            workspace_id,
25391            session: SessionKey {
25392                instance_id: AgentInstanceId(71),
25393                generation: SessionGeneration::default(),
25394            },
25395        };
25396
25397        let record_id = shared
25398            .bind_spawn_session(
25399                &address,
25400                agent("claude"),
25401                SessionMode::Pty,
25402                ProviderRuntimePolicy::raw_pty(),
25403                SpawnRecordPolicy::Always,
25404                None,
25405            )
25406            .unwrap()
25407            .unwrap();
25408        shared
25409            .handle
25410            .dispatch(shared.prepare_command(ControlCommand::Register {
25411                instance_id: address.session.instance_id,
25412                agent_id: agent("claude"),
25413                transport: TransportKind::Pty,
25414            }))
25415            .unwrap();
25416        runtime.tick().await;
25417
25418        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25419            session.instance_id == address.session.instance_id
25420                && session.generation == address.session.generation
25421        }));
25422        let record = shared.record(&record_id).unwrap();
25423        assert_eq!(record.display_name, "claude #1");
25424        assert_eq!(record.state, ManagedSessionState::Live);
25425        assert_eq!(record.active_session.as_ref(), Some(&address));
25426        assert!(record.provider_session.is_none());
25427        let binding_record_id = shared
25428            .session_bindings
25429            .lock()
25430            .unwrap_or_else(|poisoned| poisoned.into_inner())
25431            .get(&address.session.instance_id)
25432            .and_then(|binding| binding.record_id.clone());
25433        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25434
25435        let renamed = shared
25436            .rename_session_record(&record_id, "raw review".to_owned())
25437            .unwrap();
25438        assert_eq!(renamed.record_id, record_id);
25439        assert_eq!(renamed.display_name, "raw review");
25440        shared.reconcile_managed_record(
25441            &record_id,
25442            &address,
25443            &ControlEvent {
25444                sequence: 1,
25445                command_id: None,
25446                instance_id: address.session.instance_id,
25447                generation: address.session.generation,
25448                event: ControlEventKind::Running { process_id: None },
25449            },
25450            false,
25451            false,
25452        );
25453        let running = shared.record(&record_id).unwrap();
25454        assert_eq!(running.state, ManagedSessionState::Live);
25455        assert_eq!(running.active_session.as_ref(), Some(&address));
25456        assert_eq!(running.display_name, "raw review");
25457
25458        shared.reconcile_managed_record(
25459            &record_id,
25460            &address,
25461            &ControlEvent {
25462                sequence: 2,
25463                command_id: None,
25464                instance_id: address.session.instance_id,
25465                generation: address.session.generation,
25466                event: ControlEventKind::Exited {
25467                    exit_code: Some(0),
25468                    forced: false,
25469                },
25470            },
25471            false,
25472            false,
25473        );
25474        let detached = shared.record(&record_id).unwrap();
25475        assert_eq!(detached.state, ManagedSessionState::Unavailable);
25476        assert!(detached.active_session.is_none());
25477        assert_eq!(detached.display_name, "raw review");
25478        let renamed_detached = shared
25479            .rename_session_record(&record_id, "raw review detached".to_owned())
25480            .unwrap();
25481        assert_eq!(renamed_detached.record_id, record_id);
25482        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25483        assert!(renamed_detached.active_session.is_none());
25484        assert_eq!(renamed_detached.display_name, "raw review detached");
25485        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25486    }
25487
25488    #[test]
25489    fn legacy_raw_spawn_binding_remains_untracked() {
25490        let catalog = active_registry().unwrap();
25491        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25492        let workspace_id = WorkspaceId::new("primary").unwrap();
25493        let workspace = WorkspaceConfig::new(
25494            workspace_id.clone(),
25495            std::env::current_dir().unwrap(),
25496        )
25497        .unwrap();
25498        let shared = NodeShared::new(
25499            handle,
25500            "fixture-token".to_owned(),
25501            NodeId::new("node-legacy-raw-spawn").unwrap(),
25502            vec![workspace],
25503            vec![agent("claude")],
25504        );
25505        let address = SessionAddress {
25506            workspace_id,
25507            session: SessionKey {
25508                instance_id: AgentInstanceId(72),
25509                generation: SessionGeneration::default(),
25510            },
25511        };
25512
25513        let record_id = shared
25514            .bind_spawn_session(
25515                &address,
25516                agent("claude"),
25517                SessionMode::Pty,
25518                ProviderRuntimePolicy::raw_pty(),
25519                SpawnRecordPolicy::ProviderIdentityOnly,
25520                None,
25521            )
25522            .unwrap();
25523
25524        assert!(record_id.is_none());
25525        let binding_record_id = shared
25526            .session_bindings
25527            .lock()
25528            .unwrap_or_else(|poisoned| poisoned.into_inner())
25529            .get(&address.session.instance_id)
25530            .and_then(|binding| binding.record_id.clone());
25531        assert!(binding_record_id.is_none());
25532        assert!(shared.snapshot().session_records.is_empty());
25533    }
25534
25535    #[tokio::test]
25536    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25537        let catalog = active_registry().unwrap();
25538        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25539        let workspace_id = WorkspaceId::new("primary").unwrap();
25540        let workspace = WorkspaceConfig::new(
25541            workspace_id.clone(),
25542            std::env::current_dir().unwrap(),
25543        )
25544        .unwrap();
25545        let mut shared = NodeShared::new(
25546            handle,
25547            "fixture-token".to_owned(),
25548            NodeId::new("node-raw-semantic-gate").unwrap(),
25549            vec![workspace],
25550            vec![agent("claude")],
25551        );
25552        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25553            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25554        ])
25555        .unwrap();
25556        shared
25557            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25558            .unwrap();
25559        let address = SessionAddress {
25560            workspace_id,
25561            session: SessionKey {
25562                instance_id: AgentInstanceId(72),
25563                generation: SessionGeneration::default(),
25564            },
25565        };
25566        shared.bind_session_with_policy(
25567            &address,
25568            ProviderRuntimePolicy::raw_pty(),
25569            None,
25570        );
25571        shared
25572            .handle
25573            .dispatch(shared.prepare_command(ControlCommand::Register {
25574                instance_id: address.session.instance_id,
25575                agent_id: agent("claude"),
25576                transport: TransportKind::Pty,
25577            }))
25578            .unwrap();
25579        runtime.tick().await;
25580        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25581        let revision_before = shared.handle.snapshot().revision;
25582        let history_sequence_before = shared.current_sequence();
25583        let terminal_size = gate4agent_types::TerminalSize {
25584            rows: 24,
25585            columns: 80,
25586        };
25587
25588        for request in [
25589            NodeRequest::Prompt {
25590                session: address.clone(),
25591                text: "prompt".to_owned(),
25592            },
25593            NodeRequest::Paste {
25594                session: address.clone(),
25595                text: "paste".to_owned(),
25596            },
25597            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
25598            // the same semantic capabilities as `Prompt`/`Paste`. A bare
25599            // (promptless) resume is a raw PTY relaunch and IS admitted under
25600            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
25601            // in `provider_runtime.rs`; it is deliberately not exercised here.
25602            NodeRequest::Resume {
25603                session: address.clone(),
25604                terminal_size,
25605                initial_prompt: Some("resume".to_owned()),
25606            },
25607        ] {
25608            let error = process_request_inner(
25609                &shared,
25610                77,
25611                ClientRole::Operator,
25612                request,
25613            )
25614            .await
25615            .unwrap_err();
25616            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25617        }
25618
25619        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25620        assert_eq!(shared.handle.snapshot().revision, revision_before);
25621        assert_eq!(shared.current_sequence(), history_sequence_before);
25622        let bindings = shared
25623            .session_bindings
25624            .lock()
25625            .unwrap_or_else(|poisoned| poisoned.into_inner());
25626        let binding = bindings.get(&address.session.instance_id).unwrap();
25627        assert_eq!(binding.generation, address.session.generation);
25628        assert!(binding.pending_resume.is_none());
25629        assert!(binding.record_id.is_none());
25630    }
25631
25632    #[tokio::test]
25633    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25634        let catalog = active_registry().unwrap();
25635        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25636        let primary = WorkspaceConfig::new(
25637            WorkspaceId::new("primary").unwrap(),
25638            std::env::current_dir().unwrap(),
25639        )
25640        .unwrap();
25641        let shared = NodeShared::new(
25642            handle,
25643            "fixture-token".to_owned(),
25644            NodeId::new("node-1").unwrap(),
25645            vec![primary],
25646            vec![agent("claude")],
25647        );
25648        shared
25649            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25650            .unwrap();
25651        let before = shared.snapshot();
25652        let error = process_request_inner(
25653            &shared,
25654            77,
25655            ClientRole::Operator,
25656            NodeRequest::RegisterWorkspace {
25657                workspace_id: WorkspaceId::new("foreign").unwrap(),
25658                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25659            },
25660        )
25661        .await
25662        .unwrap_err();
25663        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25664        assert_eq!(error.message, "invalid-request");
25665        assert_eq!(shared.snapshot().workspaces, before.workspaces);
25666    }
25667
25668    #[tokio::test]
25669    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25670        let catalog = active_registry().unwrap();
25671        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25672        let primary_id = WorkspaceId::new("primary").unwrap();
25673        let primary = WorkspaceConfig::new(
25674            primary_id.clone(),
25675            std::env::current_dir().unwrap(),
25676        )
25677        .unwrap();
25678        let shared = NodeShared::new(
25679            handle,
25680            "fixture-token".to_owned(),
25681            NodeId::new("node-1").unwrap(),
25682            vec![primary.clone()],
25683            vec![agent("claude")],
25684        );
25685        let secondary_id = WorkspaceId::new("secondary").unwrap();
25686        let secondary = shared
25687            .register_workspace(
25688                secondary_id.clone(),
25689                std::env::temp_dir().to_string_lossy().into_owned(),
25690            )
25691            .await
25692            .unwrap();
25693        assert_eq!(secondary.workspace_id, secondary_id);
25694        assert!(secondary.sessions.is_empty());
25695        assert_eq!(shared.snapshot().workspaces.len(), 2);
25696
25697        let duplicate_id = shared
25698            .register_workspace(
25699                secondary_id.clone(),
25700                primary.canonical_root().to_owned(),
25701            )
25702            .await
25703            .unwrap_err();
25704        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25705        let duplicate_root = shared
25706            .register_workspace(
25707                WorkspaceId::new("duplicate-root").unwrap(),
25708                windows_path_text(&secondary.canonical_root).to_owned(),
25709            )
25710            .await
25711            .unwrap_err();
25712        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25713
25714        let address = SessionAddress {
25715            workspace_id: secondary_id.clone(),
25716            session: SessionKey {
25717                instance_id: AgentInstanceId(41),
25718                generation: SessionGeneration(1),
25719            },
25720        };
25721        shared.bind_session(
25722            address.session.instance_id,
25723            secondary_id.clone(),
25724            address.session.generation,
25725        );
25726        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25727        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25728        shared.remove_binding(&address);
25729        shared.unregister_workspace(&secondary_id).unwrap();
25730        assert_eq!(shared.snapshot().workspaces.len(), 1);
25731
25732        let last = shared.unregister_workspace(&primary_id).unwrap_err();
25733        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25734        let history = shared
25735            .history
25736            .lock()
25737            .unwrap_or_else(|poisoned| poisoned.into_inner());
25738        assert!(matches!(
25739            &history.events[0],
25740            NodeEventEnvelope {
25741                sequence: 1,
25742                event: NodeEvent::WorkspaceAdded { workspace },
25743            } if workspace.workspace_id == secondary_id
25744        ));
25745        assert!(matches!(
25746            &history.events[1],
25747            NodeEventEnvelope {
25748                sequence: 2,
25749                event: NodeEvent::WorkspaceRemoved { workspace_id },
25750            } if workspace_id == &secondary_id
25751        ));
25752    }
25753
25754    #[tokio::test]
25755    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25756        let secondary_root = temporary_workspace_root("provider-session-reference-index");
25757        std::fs::create_dir_all(&secondary_root).unwrap();
25758        let catalog = active_registry().unwrap();
25759        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25760        let primary_id = WorkspaceId::new("primary").unwrap();
25761        let secondary_id = WorkspaceId::new("secondary").unwrap();
25762        let shared = NodeShared::new(
25763            handle,
25764            "fixture-token".to_owned(),
25765            NodeId::new("node-session-reference-index").unwrap(),
25766            vec![
25767                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25768                    .unwrap(),
25769                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25770            ],
25771            vec![agent("claude"), agent("codex")],
25772        );
25773        let identity = ProviderSessionIdentity {
25774            key: gate4agent_types::ProviderSessionKey::SessionId,
25775            id: "provider-session-42".to_owned(),
25776            transcript_path: None,
25777        };
25778        let index_request = |workspace_id: WorkspaceId,
25779                             provider: AgentId,
25780                             identity: ProviderSessionIdentity,
25781                             display_name: &str| {
25782            NodeRequest::IndexProviderSession {
25783                workspace_id,
25784                provider,
25785                identity,
25786                display_name: display_name.to_owned(),
25787            }
25788        };
25789        let request = index_request(
25790            primary_id.clone(),
25791            agent("claude"),
25792            identity.clone(),
25793            "release shepherd",
25794        );
25795        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25796            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25797        }));
25798        let observer = process_request_inner(
25799            &shared,
25800            77,
25801            ClientRole::Observer,
25802            request.clone(),
25803        )
25804        .await
25805        .unwrap_err();
25806        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25807        let uncontrolled = process_request_inner(
25808            &shared,
25809            77,
25810            ClientRole::Operator,
25811            request.clone(),
25812        )
25813        .await
25814        .unwrap_err();
25815        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25816        shared
25817            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25818            .unwrap();
25819
25820        let indexed = process_request_inner(
25821            &shared,
25822            77,
25823            ClientRole::Operator,
25824            request.clone(),
25825        )
25826        .await
25827        .unwrap();
25828        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25829            panic!("provider session reference index returned another response");
25830        };
25831        assert_eq!(record.state, ManagedSessionState::Dormant);
25832        assert_eq!(record.mode, SessionMode::Pty);
25833        assert_eq!(record.workspace_id, primary_id);
25834        assert_eq!(record.provider_session.as_ref(), Some(&identity));
25835        assert!(record.active_session.is_none());
25836        assert!(record.environment_profile.is_none());
25837        assert!(record.bundle.is_none());
25838        assert!(record.context.is_none());
25839
25840        let repeated = process_request_inner(
25841            &shared,
25842            77,
25843            ClientRole::Operator,
25844            index_request(
25845                primary_id.clone(),
25846                agent("claude"),
25847                identity.clone(),
25848                "ignored replacement",
25849            ),
25850        )
25851        .await
25852        .unwrap();
25853        assert!(matches!(
25854            repeated,
25855            NodeResponse::ProviderSessionIndexed { record: ref existing }
25856                if existing.record_id == record.record_id
25857                    && existing.display_name == "release shepherd"
25858        ));
25859        let conflicting_workspace = process_request_inner(
25860            &shared,
25861            77,
25862            ClientRole::Operator,
25863            index_request(
25864                secondary_id,
25865                agent("claude"),
25866                identity.clone(),
25867                "release shepherd",
25868            ),
25869        )
25870        .await
25871        .unwrap_err();
25872        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25873        let conflicting_provider = process_request_inner(
25874            &shared,
25875            77,
25876            ClientRole::Operator,
25877            index_request(
25878                primary_id.clone(),
25879                agent("codex"),
25880                identity.clone(),
25881                "release shepherd",
25882            ),
25883        )
25884        .await
25885        .unwrap_err();
25886        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25887        let transcript_rejected = process_request_inner(
25888            &shared,
25889            77,
25890            ClientRole::Operator,
25891            index_request(
25892                primary_id,
25893                agent("claude"),
25894                ProviderSessionIdentity {
25895                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25896                    ..identity
25897                },
25898                "release shepherd",
25899            ),
25900        )
25901        .await
25902        .unwrap_err();
25903        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25904        let upserts = shared
25905            .history
25906            .lock()
25907            .unwrap_or_else(|poisoned| poisoned.into_inner())
25908            .events
25909            .iter()
25910            .filter(|event| matches!(
25911                &event.event,
25912                NodeEvent::SessionRecordUpserted { record: published }
25913                    if published.record_id == record.record_id
25914            ))
25915            .count();
25916        assert_eq!(upserts, 1);
25917        std::fs::remove_dir_all(secondary_root).unwrap();
25918    }
25919
25920    #[tokio::test]
25921    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25922        let fixture_root = temporary_workspace_root("standalone-independent");
25923        let target = fixture_root.join("independent");
25924        std::fs::create_dir_all(&fixture_root).unwrap();
25925        let catalog = active_registry().unwrap();
25926        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25927        let selected = WorkspaceConfig::new(
25928            WorkspaceId::new("primary").unwrap(),
25929            std::env::current_dir().unwrap(),
25930        )
25931        .unwrap();
25932        let selected_root = selected.canonical_root().to_owned();
25933        let shared = NodeShared::new(
25934            handle,
25935            "fixture-token".to_owned(),
25936            NodeId::new("node-standalone").unwrap(),
25937            vec![selected],
25938            vec![agent("claude")],
25939        );
25940        let workspace_id = WorkspaceId::new("independent").unwrap();
25941        let request = NodeRequest::CreateStandaloneWorkspace {
25942            workspace_id: workspace_id.clone(),
25943            root: opaque_windows_path(target.to_string_lossy().into_owned()),
25944            initial_branch: Some("main".to_owned()),
25945        };
25946        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25947            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25948        }));
25949        assert!(!request_uses_unnegotiated_capability(
25950            &request,
25951            &baseline_capabilities().unwrap(),
25952        ));
25953        assert!(request_uses_unnegotiated_capability(&request, &[]));
25954
25955        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25956            .await
25957            .unwrap_err();
25958        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25959        assert!(!target.exists());
25960        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25961            .await
25962            .unwrap_err();
25963        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25964        assert!(!target.exists());
25965        shared
25966            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25967            .unwrap();
25968
25969        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25970            .await
25971            .unwrap();
25972        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25973            panic!("standalone workspace creation returned another response");
25974        };
25975        assert_eq!(workspace.workspace_id, workspace_id);
25976        assert!(target.join(".git").is_dir());
25977        let branch = run_git_bounded(
25978            target.to_str().unwrap(),
25979            &["branch", "--show-current"],
25980            GIT_OUTPUT_MAX_BYTES,
25981        )
25982        .await
25983        .unwrap();
25984        assert!(branch.success);
25985        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25986        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25987        assert_eq!(worktrees.len(), 1);
25988        let listed_root = WorkspaceConfig::new(
25989            WorkspaceId::new("listed-independent").unwrap(),
25990            &worktrees[0].path,
25991        )
25992        .unwrap();
25993        assert!(platform::roots_equal(
25994            listed_root.canonical_root(),
25995            windows_path_text(&workspace.canonical_root),
25996        ));
25997        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25998        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25999            snapshot.workspace_id == workspace_id
26000                && snapshot.canonical_root == workspace.canonical_root
26001        }));
26002        assert!(shared
26003            .history
26004            .lock()
26005            .unwrap_or_else(|poisoned| poisoned.into_inner())
26006            .events
26007            .iter()
26008            .any(|event| matches!(
26009                &event.event,
26010                NodeEvent::WorkspaceAdded { workspace }
26011                    if workspace.workspace_id == workspace_id
26012            )));
26013        std::fs::remove_dir_all(fixture_root).unwrap();
26014    }
26015
26016    #[tokio::test]
26017    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26018        let fixture_root = temporary_workspace_root("standalone-compensation");
26019        let created_target = fixture_root.join("created-target");
26020        let existing_target = fixture_root.join("existing-target");
26021        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26022        let invalid_state_path = invalid_state_parent.join("state.json");
26023        std::fs::create_dir_all(&existing_target).unwrap();
26024        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26025        let catalog = active_registry().unwrap();
26026        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26027        let primary = WorkspaceConfig::new(
26028            WorkspaceId::new("primary").unwrap(),
26029            std::env::current_dir().unwrap(),
26030        )
26031        .unwrap();
26032        let mut shared = NodeShared::new(
26033            handle,
26034            "fixture-token".to_owned(),
26035            NodeId::new("node-standalone-compensation").unwrap(),
26036            vec![primary],
26037            vec![agent("claude")],
26038        );
26039        shared.state_path = Some(invalid_state_path);
26040
26041        let created_recovery = shared
26042            .create_standalone_workspace(
26043                WorkspaceId::new("compensated").unwrap(),
26044                created_target.to_string_lossy().into_owned(),
26045                Some("main".to_owned()),
26046            )
26047            .await
26048            .unwrap_err();
26049        assert_eq!(
26050            created_recovery.code,
26051            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26052        );
26053        assert!(created_target.join(".git").is_dir());
26054        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26055            workspace.workspace_id.as_str() != "compensated"
26056        }));
26057
26058        let existing_recovery = shared
26059            .create_standalone_workspace(
26060                WorkspaceId::new("restored").unwrap(),
26061                existing_target.to_string_lossy().into_owned(),
26062                None,
26063            )
26064            .await
26065            .unwrap_err();
26066        assert_eq!(
26067            existing_recovery.code,
26068            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26069        );
26070        assert!(existing_target.is_dir());
26071        assert!(existing_target.join(".git").is_dir());
26072        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26073            workspace.workspace_id.as_str() != "restored"
26074        }));
26075
26076        let registered_empty = fixture_root.join("registered-empty");
26077        std::fs::create_dir(&registered_empty).unwrap();
26078        let catalog = active_registry().unwrap();
26079        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26080        let registered = WorkspaceConfig::new(
26081            WorkspaceId::new("registered-empty").unwrap(),
26082            &registered_empty,
26083        )
26084        .unwrap();
26085        let duplicate_shared = NodeShared::new(
26086            handle,
26087            "fixture-token".to_owned(),
26088            NodeId::new("node-standalone-duplicate-root").unwrap(),
26089            vec![registered],
26090            vec![agent("claude")],
26091        );
26092        let duplicate = duplicate_shared
26093            .create_standalone_workspace(
26094                WorkspaceId::new("duplicate-root").unwrap(),
26095                registered_empty.to_string_lossy().into_owned(),
26096                None,
26097            )
26098            .await
26099            .unwrap_err();
26100        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26101        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
26102        std::fs::remove_dir_all(fixture_root).unwrap();
26103    }
26104
26105    #[test]
26106    fn managed_record_id_collision_preserves_the_existing_record() {
26107        let catalog = active_registry().unwrap();
26108        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26109        let workspace = WorkspaceConfig::new(
26110            WorkspaceId::new("primary").unwrap(),
26111            std::env::current_dir().unwrap(),
26112        )
26113        .unwrap();
26114        let shared = NodeShared::new(
26115            handle,
26116            "fixture-token".to_owned(),
26117            NodeId::new("node-1").unwrap(),
26118            vec![workspace.clone()],
26119            vec![agent("claude")],
26120        );
26121        let record_id = SessionRecordId::new("sr-collision").unwrap();
26122        let original = ManagedSessionRecord {
26123            record_id: record_id.clone(),
26124            display_name: "original".to_owned(),
26125            provider: agent("claude"),
26126            mode: SessionMode::Pty,
26127            state: ManagedSessionState::Dormant,
26128            workspace_id: workspace.workspace_id,
26129            canonical_root: opaque_windows_path(workspace.canonical_root),
26130            provider_session: None,
26131            active_session: None,
26132            environment_profile: None,
26133            bundle: None,
26134            context_id: None,
26135            context: None,
26136            exported_context: None,
26137            task_binding: None,
26138            created_at_unix_ms: 1,
26139            updated_at_unix_ms: 1,
26140            last_error: None,
26141        };
26142        shared.insert_record(original.clone()).unwrap();
26143        let mut collision = original.clone();
26144        collision.display_name = "replacement".to_owned();
26145        let error = shared.insert_record(collision).unwrap_err();
26146        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26147        assert_eq!(shared.record(&record_id).unwrap(), original);
26148    }
26149
26150    #[test]
26151    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26152        let catalog = active_registry().unwrap();
26153        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26154        let workspace = WorkspaceConfig::new(
26155            WorkspaceId::new("primary").unwrap(),
26156            std::env::current_dir().unwrap(),
26157        )
26158        .unwrap();
26159        let shared = NodeShared::new(
26160            handle,
26161            "fixture-token".to_owned(),
26162            NodeId::new("node-1").unwrap(),
26163            vec![workspace.clone()],
26164            vec![agent("claude")],
26165        );
26166        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26167        shared
26168            .insert_record(ManagedSessionRecord {
26169                record_id: record_id.clone(),
26170                display_name: "runtime error".to_owned(),
26171                provider: agent("claude"),
26172                mode: SessionMode::Pty,
26173                state: ManagedSessionState::IdentityPending,
26174                workspace_id: workspace.workspace_id,
26175                canonical_root: opaque_windows_path(workspace.canonical_root),
26176                provider_session: None,
26177                active_session: None,
26178                environment_profile: None,
26179                bundle: None,
26180                context_id: None,
26181                context: None,
26182                exported_context: None,
26183                task_binding: None,
26184                created_at_unix_ms: 1,
26185                updated_at_unix_ms: 1,
26186                last_error: None,
26187            })
26188            .unwrap();
26189
26190        shared
26191            .mark_record_error(
26192                &record_id,
26193                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26194            )
26195            .unwrap();
26196        let record = shared.record(&record_id).unwrap();
26197        assert_eq!(record.state, ManagedSessionState::Unavailable);
26198        assert_eq!(
26199            record.last_error.as_deref(),
26200            Some("provider-runtime-failed"),
26201        );
26202        let snapshot = shared.snapshot();
26203        assert_eq!(snapshot.session_records, vec![record]);
26204    }
26205
26206    /// Regression coverage for the "operator sees `deadline` instead of the
26207    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
26208    /// able to learn a command's asynchronous kernel rejection (here,
26209    /// specifically classified as `UnsupportedTransport`) exactly once, and
26210    /// must not still find it after that -- proving the fast path this
26211    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
26212    /// loop) drains the reason instead of leaving it to be rediscovered (or
26213    /// missed) on a later poll.
26214    #[test]
26215    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26216        let catalog = active_registry().unwrap();
26217        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26218        let workspace = WorkspaceConfig::new(
26219            WorkspaceId::new("primary").unwrap(),
26220            std::env::current_dir().unwrap(),
26221        )
26222        .unwrap();
26223        let shared = NodeShared::new(
26224            handle,
26225            "fixture-token".to_owned(),
26226            NodeId::new("node-1").unwrap(),
26227            vec![workspace],
26228            vec![agent("claude")],
26229        );
26230
26231        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26232
26233        shared.record_rejected_command(
26234            CommandId(1),
26235            "agent 'claude' does not support transport Acp".to_owned(),
26236            true,
26237        );
26238        // An unrelated command_id must not see another command's reason.
26239        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26240
26241        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26242        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26243        assert!(reason.unsupported_transport);
26244
26245        // Consumed: a second read for the same command_id finds nothing.
26246        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26247
26248        // A rejection unrelated to transport support keeps its own
26249        // classification distinct, so the reader can choose the generic
26250        // `BackendOperationFailed` code instead of misreporting
26251        // `UnsupportedTransport`.
26252        shared.record_rejected_command(
26253            CommandId(3),
26254            "backend busy".to_owned(),
26255            false,
26256        );
26257        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26258        assert!(!unclassified.unsupported_transport);
26259    }
26260
26261    #[test]
26262    fn provider_identity_update_is_ignored_without_exact_session_policy() {
26263        let catalog = active_registry().unwrap();
26264        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26265        let workspace = WorkspaceConfig::new(
26266            WorkspaceId::new("primary").unwrap(),
26267            std::env::current_dir().unwrap(),
26268        )
26269        .unwrap();
26270        let shared = NodeShared::new(
26271            handle,
26272            "fixture-token".to_owned(),
26273            NodeId::new("node-identity-policy").unwrap(),
26274            vec![workspace.clone()],
26275            vec![agent("claude")],
26276        );
26277        let address = SessionAddress {
26278            workspace_id: workspace.workspace_id.clone(),
26279            session: SessionKey {
26280                instance_id: AgentInstanceId(90),
26281                generation: SessionGeneration(1),
26282            },
26283        };
26284        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26285        shared
26286            .insert_record(ManagedSessionRecord {
26287                record_id: record_id.clone(),
26288                display_name: "policy denied".to_owned(),
26289                provider: agent("claude"),
26290                mode: SessionMode::Pty,
26291                state: ManagedSessionState::IdentityPending,
26292                workspace_id: workspace.workspace_id,
26293                canonical_root: opaque_windows_path(workspace.canonical_root),
26294                provider_session: None,
26295                active_session: Some(address.clone()),
26296                environment_profile: None,
26297                bundle: None,
26298                context_id: None,
26299                context: None,
26300                exported_context: None,
26301                task_binding: None,
26302                created_at_unix_ms: 1,
26303                updated_at_unix_ms: 1,
26304                last_error: None,
26305            })
26306            .unwrap();
26307        let event = ControlEvent {
26308            sequence: 1,
26309            command_id: None,
26310            instance_id: address.session.instance_id,
26311            generation: address.session.generation,
26312            event: ControlEventKind::ProviderEvent {
26313                sequence: 1,
26314                source: gate4agent_types::ProviderSource {
26315                    family: AdapterFamily::PtySemantic,
26316                    binding: AdapterBinding::new(
26317                        gate4agent_types::AdapterId::new("claude").unwrap(),
26318                        "fixture/v1",
26319                        gate4agent_types::AdapterVerification::SyntheticFixture,
26320                    )
26321                    .unwrap(),
26322                },
26323                source_sequence: 1,
26324                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26325                    identity: gate4agent_types::ProviderSessionIdentity {
26326                        key: gate4agent_types::ProviderSessionKey::SessionId,
26327                        id: "provider-session".to_owned(),
26328                        transcript_path: None,
26329                    },
26330                },
26331            },
26332        };
26333
26334        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26335
26336        let record = shared.record(&record_id).unwrap();
26337        assert!(record.provider_session.is_none());
26338        assert_eq!(record.state, ManagedSessionState::IdentityPending);
26339        assert_eq!(record.active_session.as_ref(), Some(&address));
26340    }
26341
26342    #[test]
26343    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26344        let secondary_root = temporary_workspace_root("identity-scope");
26345        std::fs::create_dir_all(&secondary_root).unwrap();
26346        let catalog = active_registry().unwrap();
26347        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26348        let primary = WorkspaceConfig::new(
26349            WorkspaceId::new("primary").unwrap(),
26350            std::env::current_dir().unwrap(),
26351        )
26352        .unwrap();
26353        let secondary = WorkspaceConfig::new(
26354            WorkspaceId::new("secondary").unwrap(),
26355            &secondary_root,
26356        )
26357        .unwrap();
26358        let shared = NodeShared::new(
26359            handle,
26360            "fixture-token".to_owned(),
26361            NodeId::new("node-1").unwrap(),
26362            vec![primary.clone(), secondary.clone()],
26363            vec![agent("claude")],
26364        );
26365        let identity = gate4agent_types::ProviderSessionIdentity {
26366            key: gate4agent_types::ProviderSessionKey::SessionId,
26367            id: "provider-session".to_owned(),
26368            transcript_path: None,
26369        };
26370        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26371        shared
26372            .insert_record(ManagedSessionRecord {
26373                record_id: dormant_id.clone(),
26374                display_name: "dormant".to_owned(),
26375                provider: agent("claude"),
26376                mode: SessionMode::Pty,
26377                state: ManagedSessionState::Dormant,
26378                workspace_id: primary.workspace_id,
26379                canonical_root: opaque_windows_path(primary.canonical_root),
26380                provider_session: Some(identity.clone()),
26381                active_session: None,
26382                environment_profile: None,
26383                bundle: None,
26384                context_id: None,
26385                context: None,
26386                exported_context: None,
26387                task_binding: None,
26388                created_at_unix_ms: 1,
26389                updated_at_unix_ms: 1,
26390                last_error: None,
26391            })
26392            .unwrap();
26393        let address = SessionAddress {
26394            workspace_id: secondary.workspace_id.clone(),
26395            session: SessionKey {
26396                instance_id: AgentInstanceId(91),
26397                generation: SessionGeneration(1),
26398            },
26399        };
26400        let current_id = SessionRecordId::new("sr-current").unwrap();
26401        shared
26402            .insert_record(ManagedSessionRecord {
26403                record_id: current_id.clone(),
26404                display_name: "current".to_owned(),
26405                provider: agent("claude"),
26406                mode: SessionMode::Pty,
26407                state: ManagedSessionState::IdentityPending,
26408                workspace_id: secondary.workspace_id,
26409                canonical_root: opaque_windows_path(secondary.canonical_root),
26410                provider_session: None,
26411                active_session: Some(address.clone()),
26412                environment_profile: None,
26413                bundle: None,
26414                context_id: None,
26415                context: None,
26416                exported_context: None,
26417                task_binding: None,
26418                created_at_unix_ms: 2,
26419                updated_at_unix_ms: 2,
26420                last_error: None,
26421            })
26422            .unwrap();
26423        shared.bind_managed_session(
26424            &address,
26425            current_id.clone(),
26426            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26427            None,
26428        );
26429        let event = ControlEvent {
26430            sequence: 1,
26431            command_id: None,
26432            instance_id: address.session.instance_id,
26433            generation: address.session.generation,
26434            event: ControlEventKind::ProviderEvent {
26435                sequence: 1,
26436                source: gate4agent_types::ProviderSource {
26437                    family: gate4agent_types::AdapterFamily::PtySemantic,
26438                    binding: gate4agent_types::AdapterBinding::new(
26439                        gate4agent_types::AdapterId::new("claude").unwrap(),
26440                        "fixture/v1",
26441                        gate4agent_types::AdapterVerification::SyntheticFixture,
26442                    )
26443                    .unwrap(),
26444                },
26445                source_sequence: 1,
26446                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26447                    identity,
26448                },
26449            },
26450        };
26451        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
26452
26453        let binding = shared
26454            .session_bindings
26455            .lock()
26456            .unwrap_or_else(|poisoned| poisoned.into_inner())
26457            .get(&address.session.instance_id)
26458            .cloned()
26459            .unwrap();
26460        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
26461        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26462        let current = shared.record(&current_id).unwrap();
26463        assert_eq!(current.state, ManagedSessionState::Unavailable);
26464        assert_eq!(current.active_session.as_ref(), Some(&address));
26465        assert_eq!(
26466            current.last_error.as_deref(),
26467            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26468        );
26469        std::fs::remove_dir_all(secondary_root).unwrap();
26470    }
26471
26472    #[tokio::test]
26473    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26474        let root = temporary_workspace_root("semantic-provider-session");
26475        std::fs::create_dir_all(&root).unwrap();
26476        let state_path = root.join("state-v1.json");
26477        let catalog = active_registry().unwrap();
26478        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26479        let workspace = WorkspaceConfig::new(
26480            WorkspaceId::new("primary").unwrap(),
26481            &root,
26482        )
26483        .unwrap();
26484        let node_id = NodeId::new("node-semantic-identity").unwrap();
26485        let shared = NodeShared::new_with_incarnation(
26486            handle,
26487            "fixture-token".to_owned(),
26488            node_id.clone(),
26489            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26490            vec![workspace.clone()],
26491            vec![agent("claude")],
26492            ProviderRuntimeStatuses::default(),
26493            None,
26494            Vec::new(),
26495            Vec::new(),
26496            SpawnProfileRegistry::default(),
26497            None,
26498            None,
26499            None,
26500            Some(state_path.clone()),
26501            Vec::new(),
26502            Vec::new(),
26503            Vec::new(),
26504            Vec::new(),
26505            None,
26506            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26507        );
26508        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26509            key: gate4agent_types::ProviderSessionKey::SessionId,
26510            id: "provider-session".to_owned(),
26511            transcript_path: None,
26512        };
26513        let durable_id = SessionRecordId::new("sr-durable").unwrap();
26514        shared
26515            .insert_record(ManagedSessionRecord {
26516                record_id: durable_id.clone(),
26517                display_name: "durable".to_owned(),
26518                provider: agent("claude"),
26519                mode: SessionMode::Pty,
26520                state: ManagedSessionState::Dormant,
26521                workspace_id: workspace.workspace_id.clone(),
26522                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26523                provider_session: Some(persisted_identity.clone()),
26524                active_session: None,
26525                environment_profile: None,
26526                bundle: None,
26527                context_id: None,
26528                context: None,
26529                exported_context: None,
26530                task_binding: None,
26531                created_at_unix_ms: 1,
26532                updated_at_unix_ms: 1,
26533                last_error: None,
26534            })
26535            .unwrap();
26536        let address = SessionAddress {
26537            workspace_id: workspace.workspace_id.clone(),
26538            session: SessionKey {
26539                instance_id: AgentInstanceId(92),
26540                generation: SessionGeneration(2),
26541            },
26542        };
26543        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26544        shared
26545            .insert_record(ManagedSessionRecord {
26546                record_id: pending_id.clone(),
26547                display_name: "pending".to_owned(),
26548                provider: agent("claude"),
26549                mode: SessionMode::Pty,
26550                state: ManagedSessionState::IdentityPending,
26551                workspace_id: workspace.workspace_id.clone(),
26552                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26553                provider_session: None,
26554                active_session: Some(address.clone()),
26555                environment_profile: None,
26556                bundle: None,
26557                context_id: None,
26558                context: None,
26559                exported_context: None,
26560                task_binding: None,
26561                created_at_unix_ms: 2,
26562                updated_at_unix_ms: 2,
26563                last_error: None,
26564            })
26565            .unwrap();
26566        shared.bind_managed_session(
26567            &address,
26568            pending_id.clone(),
26569            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26570            None,
26571        );
26572        let observed_identity = gate4agent_types::ProviderSessionIdentity {
26573            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26574            ..persisted_identity
26575        };
26576        let event = ControlEvent {
26577            sequence: 1,
26578            command_id: None,
26579            instance_id: address.session.instance_id,
26580            generation: address.session.generation,
26581            event: ControlEventKind::ProviderEvent {
26582                sequence: 1,
26583                source: gate4agent_types::ProviderSource {
26584                    family: gate4agent_types::AdapterFamily::PtySemantic,
26585                    binding: gate4agent_types::AdapterBinding::new(
26586                        gate4agent_types::AdapterId::new("claude").unwrap(),
26587                        "fixture/v1",
26588                        gate4agent_types::AdapterVerification::SyntheticFixture,
26589                    )
26590                    .unwrap(),
26591                },
26592                source_sequence: 1,
26593                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26594                    identity: observed_identity.clone(),
26595                },
26596            },
26597        };
26598        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26599
26600        let snapshot = shared.snapshot();
26601        assert_eq!(snapshot.session_records.len(), 1);
26602        let live = &snapshot.session_records[0];
26603        assert_eq!(live.record_id, durable_id);
26604        assert_eq!(live.state, ManagedSessionState::Live);
26605        assert_eq!(live.active_session.as_ref(), Some(&address));
26606        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26607        let binding = shared
26608            .session_bindings
26609            .lock()
26610            .unwrap_or_else(|poisoned| poisoned.into_inner())
26611            .get(&address.session.instance_id)
26612            .cloned()
26613            .unwrap();
26614        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26615
26616        let mut duplicate = live.clone();
26617        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26618        duplicate.display_name = "duplicate".to_owned();
26619        duplicate.state = ManagedSessionState::Dormant;
26620        duplicate.active_session = None;
26621        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26622        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26623        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26624        assert_eq!(shared.snapshot().session_records.len(), 1);
26625
26626        let terminal_size = gate4agent_types::TerminalSize {
26627            rows: 24,
26628            columns: 80,
26629        };
26630        let (first_busy, second_busy) = tokio::join!(
26631            shared.resume_session_record(&durable_id, terminal_size, None),
26632            shared.resume_session_record(&durable_id, terminal_size, None),
26633        );
26634        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26635            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26636            assert_eq!(busy.message, "session-record-busy");
26637        }
26638
26639        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26640        assert_eq!(loaded.records.len(), 1);
26641        assert_eq!(loaded.records[0].record_id, durable_id);
26642        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26643        assert_eq!(
26644            loaded.records[0]
26645                .provider_session
26646                .as_ref()
26647                .unwrap()
26648                .transcript_path,
26649            None,
26650        );
26651        let catalog = active_registry().unwrap();
26652        let (reloaded_handle, _reloaded_runtime) =
26653            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26654        let reloaded = NodeShared::new_with_incarnation(
26655            reloaded_handle,
26656            "fixture-token".to_owned(),
26657            node_id,
26658            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26659            vec![workspace],
26660            vec![agent("claude")],
26661            ProviderRuntimeStatuses::default(),
26662            None,
26663            Vec::new(),
26664            Vec::new(),
26665            SpawnProfileRegistry::default(),
26666            None,
26667            None,
26668            None,
26669            None,
26670            loaded.records,
26671            loaded.managed_worktrees,
26672            loaded.managed_worktree_tombstones,
26673            loaded.materializations,
26674            loaded.warning,
26675            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26676        );
26677        assert_eq!(reloaded.snapshot().session_records.len(), 1);
26678        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26679        std::fs::remove_dir_all(root).unwrap();
26680    }
26681
26682    #[tokio::test]
26683    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26684        let secondary_root = temporary_workspace_root("workspace-restore");
26685        let other_root = temporary_workspace_root("workspace-conflict");
26686        std::fs::create_dir_all(&secondary_root).unwrap();
26687        std::fs::create_dir_all(&other_root).unwrap();
26688        let catalog = active_registry().unwrap();
26689        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26690        let primary = WorkspaceConfig::new(
26691            WorkspaceId::new("primary").unwrap(),
26692            std::env::current_dir().unwrap(),
26693        )
26694        .unwrap();
26695        let secondary = WorkspaceConfig::new(
26696            WorkspaceId::new("secondary").unwrap(),
26697            &secondary_root,
26698        )
26699        .unwrap();
26700        let shared = NodeShared::new(
26701            handle,
26702            "fixture-token".to_owned(),
26703            NodeId::new("node-1").unwrap(),
26704            vec![primary, secondary.clone()],
26705            vec![agent("claude")],
26706        );
26707        let record_id = SessionRecordId::new("sr-workspace").unwrap();
26708        shared
26709            .insert_record(ManagedSessionRecord {
26710                record_id: record_id.clone(),
26711                display_name: "workspace record".to_owned(),
26712                provider: agent("claude"),
26713                mode: SessionMode::Pty,
26714                state: ManagedSessionState::Dormant,
26715                workspace_id: secondary.workspace_id.clone(),
26716                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26717                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26718                    key: gate4agent_types::ProviderSessionKey::SessionId,
26719                    id: "provider-session".to_owned(),
26720                    transcript_path: None,
26721                }),
26722                active_session: None,
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: 1,
26730                updated_at_unix_ms: 1,
26731                last_error: None,
26732            })
26733            .unwrap();
26734        shared.unregister_workspace(&secondary.workspace_id).unwrap();
26735        let unavailable = shared.record(&record_id).unwrap();
26736        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26737        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26738
26739        let conflict = shared
26740            .register_workspace(
26741                secondary.workspace_id.clone(),
26742                other_root.to_string_lossy().into_owned(),
26743            )
26744            .await
26745            .unwrap_err();
26746        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26747        shared
26748            .register_workspace(
26749                secondary.workspace_id,
26750                secondary_root.to_string_lossy().into_owned(),
26751            )
26752            .await
26753            .unwrap();
26754        let restored = shared.record(&record_id).unwrap();
26755        assert_eq!(restored.state, ManagedSessionState::Dormant);
26756        assert!(restored.last_error.is_none());
26757        std::fs::remove_dir_all(secondary_root).unwrap();
26758        std::fs::remove_dir_all(other_root).unwrap();
26759    }
26760
26761    #[test]
26762    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26763        let error = validate_workspace_request_root(
26764            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26765        )
26766        .unwrap_err();
26767        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26768        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26769        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26770    }
26771
26772    #[test]
26773    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26774        let catalog = active_registry().unwrap();
26775        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26776        let workspace = WorkspaceConfig::new(
26777            WorkspaceId::new("primary").unwrap(),
26778            std::env::current_dir().unwrap(),
26779        )
26780        .unwrap();
26781        let shared = NodeShared::new(
26782            handle,
26783            "fixture-token".to_owned(),
26784            NodeId::new("node-1").unwrap(),
26785            vec![workspace],
26786            vec![agent("claude")],
26787        );
26788        let publishers = 4_u64;
26789        let events_per_publisher = 256_u64;
26790        let start = std::sync::Barrier::new(publishers as usize + 1);
26791        let finished = AtomicU64::new(0);
26792
26793        std::thread::scope(|scope| {
26794            for _ in 0..publishers {
26795                scope.spawn(|| {
26796                    start.wait();
26797                    for _ in 0..events_per_publisher {
26798                        shared.publish(NodeEvent::ControllerChanged { controller: None });
26799                    }
26800                    finished.fetch_add(1, Ordering::Release);
26801                });
26802            }
26803            start.wait();
26804            while finished.load(Ordering::Acquire) != publishers {
26805                assert_resync_history_is_linearized(shared.resync(0));
26806                std::thread::yield_now();
26807            }
26808        });
26809        let response = shared.resync(0);
26810        assert_resync_history_is_linearized(response.clone());
26811        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26812            unreachable!("resync helper returned another response");
26813        };
26814        assert_eq!(event_sequence, publishers * events_per_publisher);
26815        assert_eq!(events.len() as u64, event_sequence);
26816    }
26817
26818    fn assert_resync_history_is_linearized(response: NodeResponse) {
26819        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26820            unreachable!("resync helper returned another response");
26821        };
26822        assert!(events.windows(2).all(|pair| {
26823            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26824        }));
26825        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26826    }
26827
26828    #[test]
26829    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26830        assert_eq!(
26831            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26832            r"C:\repo\worktree",
26833        );
26834        assert_eq!(
26835            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26836            r"\\server\share\repo",
26837        );
26838        assert!(validate_workspace_root(
26839            &WorkspaceId::new("network").unwrap(),
26840            r"\\server\share\repo",
26841        )
26842        .is_err());
26843        let workspace = WorkspaceConfig::new(
26844            WorkspaceId::new("primary").unwrap(),
26845            std::env::current_dir().unwrap(),
26846        )
26847        .unwrap();
26848        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26849    }
26850
26851    #[test]
26852    fn resume_rebinds_only_on_the_expected_authorized_generation() {
26853        let catalog = active_registry().unwrap();
26854        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26855        let workspace_id = WorkspaceId::new("primary").unwrap();
26856        let workspace = WorkspaceConfig::new(
26857            workspace_id.clone(),
26858            std::env::current_dir().unwrap(),
26859        )
26860        .unwrap();
26861        let shared = NodeShared::new(
26862            handle,
26863            "fixture-token".to_owned(),
26864            NodeId::new("node-1").unwrap(),
26865            vec![workspace],
26866            vec![agent("claude")],
26867        );
26868        let instance_id = AgentInstanceId(17);
26869        let original = SessionAddress {
26870            workspace_id,
26871            session: SessionKey {
26872                instance_id,
26873                generation: SessionGeneration(3),
26874            },
26875        };
26876        shared.bind_session(
26877            instance_id,
26878            original.workspace_id.clone(),
26879            original.session.generation,
26880        );
26881        shared
26882            .arm_resume(
26883                &original,
26884                CommandId(41),
26885                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26886            )
26887            .unwrap();
26888
26889        shared.publish_control(ControlEvent {
26890            sequence: 1,
26891            command_id: None,
26892            instance_id,
26893            generation: SessionGeneration(4),
26894            event: ControlEventKind::Running { process_id: None },
26895        });
26896        assert_eq!(
26897            shared.address_for(instance_id, SessionGeneration(3)),
26898            Some(original.clone()),
26899        );
26900        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26901
26902        shared.publish_control(ControlEvent {
26903            sequence: 2,
26904            command_id: Some(CommandId(42)),
26905            instance_id,
26906            generation: SessionGeneration(3),
26907            event: ControlEventKind::CommandRejected {
26908                message: "unrelated command".to_owned(),
26909            },
26910        });
26911
26912        shared.publish_control(ControlEvent {
26913            sequence: 3,
26914            command_id: None,
26915            instance_id,
26916            generation: SessionGeneration(4),
26917            event: ControlEventKind::ResumeAuthorized {
26918                session: gate4agent_types::ResumeSessionSummary {
26919                    key: gate4agent_types::ProviderSessionKey::SessionId,
26920                    id: "fixture-session".to_owned(),
26921                },
26922            },
26923        });
26924        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26925        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26926        assert_eq!(rebound.workspace_id, original.workspace_id);
26927        let history = shared
26928            .history
26929            .lock()
26930            .unwrap_or_else(|poisoned| poisoned.into_inner());
26931        assert!(history.events.iter().any(|envelope| matches!(
26932            &envelope.event,
26933            NodeEvent::Control { address, event }
26934                if address == &rebound
26935                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26936        )));
26937    }
26938
26939    #[test]
26940    fn all_node_pty_prompts_use_bracketed_paste_framing() {
26941        for agent in ["claude", "codex", "kimi"] {
26942            assert_eq!(
26943                prompt_framing(&AgentId::new(agent).unwrap()),
26944                PromptFraming::BracketedPaste
26945            );
26946        }
26947    }
26948
26949    fn generous_walk_deadline() -> Instant {
26950        Instant::now() + Duration::from_secs(30)
26951    }
26952
26953    #[test]
26954    fn workspace_tree_is_relative_and_skips_heavy_directories() {
26955        let root = temporary_workspace_root("tree");
26956        std::fs::create_dir_all(root.join("src")).unwrap();
26957        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26958        std::fs::create_dir_all(root.join("target/debug")).unwrap();
26959        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26960        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26961        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26962        // A renamed/agent-scoped build directory (`target-a2`-style, per the
26963        // real repository's own build layout) that carries the Cache
26964        // Directory Tagging Specification marker cargo writes into every
26965        // `--target-dir` it creates: must be skipped even though its name
26966        // does not exactly match `target`.
26967        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26968        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26969        // A directory that merely *starts with* `target` but carries no
26970        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
26971        // on the name alone, so this stays in the response.
26972        std::fs::create_dir_all(root.join("target-notes")).unwrap();
26973        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26974
26975        let (entries, truncated, _budget) =
26976            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26977        assert!(!truncated);
26978        assert!(entries.iter().any(|entry| {
26979            entry.relative_path.as_utf8() == Some("src")
26980                && entry.kind == WorkspaceEntryKind::Directory
26981        }));
26982        assert!(entries.iter().any(|entry| {
26983            entry.relative_path.as_utf8() == Some("src/lib.rs")
26984                && entry.kind == WorkspaceEntryKind::File
26985        }));
26986        assert!(entries.iter().any(|entry| {
26987            entry.relative_path.as_utf8() == Some("target-notes")
26988                && entry.kind == WorkspaceEntryKind::Directory
26989        }));
26990        assert!(entries.iter().any(|entry| {
26991            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26992                && entry.kind == WorkspaceEntryKind::File
26993        }));
26994        assert!(entries.iter().all(|entry| {
26995            let path = entry.relative_path.as_utf8().unwrap();
26996            !path.starts_with(".git")
26997                && !(path == "target" || path.starts_with("target/"))
26998                && !(path == "target-a2" || path.starts_with("target-a2/"))
26999                && !path.starts_with("node_modules")
27000        }));
27001
27002        std::fs::remove_dir_all(&root).unwrap();
27003    }
27004
27005    #[test]
27006    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27007        let root = temporary_workspace_root("tree-entry-cap");
27008        std::fs::create_dir_all(&root).unwrap();
27009        for index in 0..20 {
27010            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27011        }
27012
27013        let (entries, truncated, budget) =
27014            collect_workspace_entries(&root, generous_walk_deadline(), 5);
27015        assert!(truncated);
27016        assert!(budget.entry_cap_exceeded);
27017        assert!(!budget.time_budget_exceeded);
27018        assert!(entries.len() <= 5);
27019
27020        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27021            .expect("an entry-cap-truncated walk must report a truncation marker");
27022        assert!(inspection_truncation.walk_entry_cap_exceeded);
27023        assert!(!inspection_truncation.walk_time_budget_exceeded);
27024        assert!(!inspection_truncation.git_time_budget_exceeded);
27025        assert!(inspection_truncation.entries_visited >= 5);
27026
27027        std::fs::remove_dir_all(&root).unwrap();
27028    }
27029
27030    #[test]
27031    fn workspace_walk_within_budget_reports_no_truncation() {
27032        let root = temporary_workspace_root("tree-no-truncation");
27033        std::fs::create_dir_all(&root).unwrap();
27034        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27035
27036        let (_entries, truncated, budget) =
27037            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27038        assert!(!truncated);
27039        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27040
27041        std::fs::remove_dir_all(&root).unwrap();
27042    }
27043
27044    #[test]
27045    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27046        let root = temporary_workspace_root("tree-prefix-siblings");
27047        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27048        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27049        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27050        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27051
27052        let (entries, truncated, _budget) =
27053            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27054        assert!(!truncated);
27055        let paths = entries
27056            .iter()
27057            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27058            .collect::<Vec<_>>();
27059        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27060        assert_eq!(
27061            paths,
27062            vec![
27063                "gate4agent-c2".to_owned(),
27064                "gate4agent-c2-client".to_owned(),
27065                "gate4agent-c2-client/lib.rs".to_owned(),
27066                "gate4agent-c2/src".to_owned(),
27067                "gate4agent-c2/src/lib.rs".to_owned(),
27068            ],
27069        );
27070
27071        std::fs::remove_dir_all(&root).unwrap();
27072    }
27073
27074    #[test]
27075    fn git_parsers_cap_status_and_commit_results() {
27076        let mut status_output = Vec::new();
27077        for index in 0..=GIT_STATUS_MAX_ENTRIES {
27078            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27079        }
27080        let mut snapshot = git_snapshot_for_parser();
27081        parse_git_status(&status_output, &mut snapshot);
27082        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27083        assert!(snapshot.truncated);
27084        assert_eq!(snapshot.status[0].index_status, " ");
27085        assert_eq!(snapshot.status[0].worktree_status, "M");
27086
27087        snapshot.truncated = false;
27088        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27089            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27090            .collect::<String>();
27091        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27092        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27093        assert!(snapshot.truncated);
27094    }
27095
27096    #[test]
27097    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27098        let mut snapshot = git_snapshot_for_parser();
27099        parse_git_status(
27100            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27101            &mut snapshot,
27102        );
27103
27104        assert!(!snapshot.truncated);
27105        assert_eq!(snapshot.status.len(), 3);
27106        assert_eq!(snapshot.status[0].index_status, "R");
27107        assert_eq!(snapshot.status[0].worktree_status, " ");
27108        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27109        assert_eq!(
27110            snapshot.status[0]
27111                .previous_path
27112                .as_ref()
27113                .and_then(RepositoryPath::as_utf8),
27114            Some("src/previous name.rs"),
27115        );
27116        assert_eq!(snapshot.status[1].index_status, " ");
27117        assert_eq!(snapshot.status[1].worktree_status, "C");
27118        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27119        assert_eq!(
27120            snapshot.status[1]
27121                .previous_path
27122                .as_ref()
27123                .and_then(RepositoryPath::as_utf8),
27124            Some("src/original.rs"),
27125        );
27126        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27127        assert!(snapshot.status[2].previous_path.is_none());
27128    }
27129
27130    #[test]
27131    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27132        let mut snapshot = git_snapshot_for_parser();
27133        parse_git_status(
27134            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
27135            &mut snapshot,
27136        );
27137
27138        assert!(snapshot.truncated);
27139        assert_eq!(snapshot.status.len(), 1);
27140        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27141        assert!(snapshot.status[0].previous_path.is_none());
27142    }
27143
27144    #[tokio::test]
27145    async fn observer_can_browse_host_directories_without_controller() {
27146        let root = temporary_workspace_root("host-directory-observer");
27147        std::fs::create_dir_all(root.join("directory-b")).unwrap();
27148        std::fs::create_dir_all(root.join("directory-a")).unwrap();
27149        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27150        let workspace_id = WorkspaceId::new("primary").unwrap();
27151        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27152        let catalog = active_registry().unwrap();
27153        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27154        let shared = NodeShared::new(
27155            handle,
27156            "fixture-token".to_owned(),
27157            NodeId::new("node-1").unwrap(),
27158            vec![workspace],
27159            vec![agent("claude")],
27160        );
27161        let request = NodeRequest::BrowseHostDirectories {
27162            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27163            after: None,
27164        };
27165        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27166            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27167        }));
27168        assert!(!request_uses_unnegotiated_capability(
27169            &request,
27170            &baseline_capabilities().unwrap(),
27171        ));
27172        assert!(request_uses_unnegotiated_capability(&request, &[]));
27173
27174        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27175            .await
27176            .unwrap();
27177        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27178            panic!("host directory browse returned another response");
27179        };
27180        assert_eq!(
27181            listing
27182                .entries
27183                .iter()
27184                .map(|entry| entry.display_name.as_str())
27185                .collect::<Vec<_>>(),
27186            vec!["directory-a", "directory-b"],
27187        );
27188        assert!(listing.entries.iter().all(|entry| !entry.is_link));
27189        assert!(!listing.incomplete);
27190        std::fs::remove_dir_all(root).unwrap();
27191    }
27192
27193    #[tokio::test]
27194    async fn observer_can_inspect_only_a_registered_workspace() {
27195        let root = temporary_workspace_root("observer");
27196        std::fs::create_dir_all(root.join("src")).unwrap();
27197        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27198        let workspace_id = WorkspaceId::new("primary").unwrap();
27199        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27200        let catalog = active_registry().unwrap();
27201        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27202        let shared = NodeShared::new(
27203            handle,
27204            "fixture-token".to_owned(),
27205            NodeId::new("node-1").unwrap(),
27206            vec![workspace],
27207            vec![agent("claude")],
27208        );
27209
27210        let response = process_request_inner(
27211            &shared,
27212            77,
27213            ClientRole::Observer,
27214            NodeRequest::InspectWorkspace {
27215                workspace_id: workspace_id.clone(),
27216            },
27217        )
27218        .await
27219        .unwrap();
27220        let NodeResponse::WorkspaceInspected { inspection } = response else {
27221            panic!("workspace inspection returned another response");
27222        };
27223        assert_eq!(inspection.workspace_id, workspace_id);
27224        assert!(inspection.entries.iter().any(|entry| {
27225            entry.relative_path.as_utf8() == Some("src/main.rs")
27226        }));
27227
27228        let unknown = process_request_inner(
27229            &shared,
27230            77,
27231            ClientRole::Observer,
27232            NodeRequest::InspectWorkspace {
27233                workspace_id: WorkspaceId::new("unknown").unwrap(),
27234            },
27235        )
27236        .await
27237        .unwrap_err();
27238        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27239        std::fs::remove_dir_all(&root).unwrap();
27240    }
27241
27242    #[test]
27243    fn context_pack_source_accepts_only_running_or_successful_exit() {
27244        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27245        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27246            exit_code: Some(0),
27247        }));
27248        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27249            exit_code: Some(1),
27250        }));
27251        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27252            exit_code: None,
27253        }));
27254        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27255            message: "provider failed".to_owned(),
27256        }));
27257        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27258        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27259        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27260    }
27261
27262    #[test]
27263    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27264        let git = git_snapshot_for_parser();
27265        let first = ContextPackRepositoryObservation {
27266            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27267            git: git.clone(),
27268            files: Vec::new(),
27269        };
27270        let second = ContextPackRepositoryObservation {
27271            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27272            git,
27273            files: Vec::new(),
27274        };
27275
27276        let error = stable_context_pack_repository(first, second).unwrap_err();
27277
27278        assert_eq!(
27279            error.code,
27280            NodeFailureCode::ContextPackMaterializationFailed,
27281        );
27282    }
27283
27284    #[test]
27285    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27286        let git = git_snapshot_for_parser();
27287        let first = ContextPackRepositoryObservation {
27288            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27289            git: git.clone(),
27290            files: vec![ContextPackRepositoryFileSource::utf8(
27291                "README.md",
27292                "first\n".to_owned(),
27293                6,
27294            )],
27295        };
27296        let second = ContextPackRepositoryObservation {
27297            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27298            git,
27299            files: vec![ContextPackRepositoryFileSource::utf8(
27300                "README.md",
27301                "other\n".to_owned(),
27302                6,
27303            )],
27304        };
27305
27306        let error = stable_context_pack_repository(first, second).unwrap_err();
27307
27308        assert_eq!(
27309            error.code,
27310            NodeFailureCode::ContextPackMaterializationFailed,
27311        );
27312    }
27313
27314    #[tokio::test]
27315    async fn native_session_catalog_zero_active_session_fixture() {
27316        let workspace_root = temporary_workspace_root("native-catalog-workspace");
27317        let history_root = temporary_workspace_root("native-catalog-history");
27318        let outside_root = temporary_workspace_root("native-catalog-outside");
27319        std::fs::create_dir_all(&workspace_root).unwrap();
27320        std::fs::create_dir_all(&history_root).unwrap();
27321        std::fs::create_dir_all(&outside_root).unwrap();
27322        let transcript = |session_id: &str, cwd: &Path, title: &str| {
27323            [
27324                serde_json::json!({
27325                    "type": "user",
27326                    "sessionId": session_id,
27327                    "cwd": cwd.to_str().unwrap(),
27328                    "message": { "content": "raw user text must not cross the catalog wire" }
27329                }),
27330                serde_json::json!({
27331                    "type": "assistant",
27332                    "sessionId": session_id,
27333                    "cwd": cwd.to_str().unwrap(),
27334                    "message": { "model": "claude-sonnet", "content": "raw answer" }
27335                }),
27336                serde_json::json!({
27337                    "type": "ai-title",
27338                    "sessionId": session_id,
27339                    "aiTitle": title
27340                }),
27341            ]
27342            .into_iter()
27343            .map(|record| record.to_string())
27344            .collect::<Vec<_>>()
27345            .join("\n")
27346        };
27347        std::fs::write(
27348            history_root.join("stable-session.jsonl"),
27349            transcript("stable-session", &workspace_root, "Workspace review"),
27350        )
27351        .unwrap();
27352        std::fs::write(
27353            history_root.join("outside-session.jsonl"),
27354            transcript("outside-session", &outside_root, "Outside review"),
27355        )
27356        .unwrap();
27357        let history = NativeHistoryConfig::new(vec![
27358            NativeHistoryRoot::new(
27359                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27360                HistorySourceLayout::SingleNdjson,
27361                history_root.clone(),
27362            )
27363            .unwrap(),
27364        ])
27365        .unwrap();
27366        let catalog = active_registry().unwrap();
27367        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27368        assert!(handle.snapshot().sessions.is_empty());
27369        let workspace_id = WorkspaceId::new("primary").unwrap();
27370        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27371        let mut shared = NodeShared::new(
27372            handle,
27373            "fixture-token".to_owned(),
27374            NodeId::new("node-native-catalog").unwrap(),
27375            vec![workspace],
27376            vec![agent("claude")],
27377        );
27378        shared.native_session_catalog = Some(Arc::new(Mutex::new(
27379            NativeSessionCatalogAuthority::new(history),
27380        )));
27381        let route = NativeSessionCatalogRoute::workspace(
27382            workspace_id.clone(),
27383            agent("claude"),
27384        );
27385        let request = NodeRequest::CatalogNativeSessions {
27386            route: route.clone(),
27387            limit: 2,
27388        };
27389        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27390            .await
27391            .unwrap_err();
27392        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27393        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27394            .await
27395            .unwrap();
27396        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27397            panic!("native catalog returned a different response");
27398        };
27399        let summary = summary.expect("node must emit native session catalog summary metadata");
27400        assert_eq!(entries.len(), 1);
27401        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27402        assert_eq!(entries[0].external_group, None);
27403        assert_eq!(entries[0].record_id, None);
27404        assert_eq!(entries[0].title, None);
27405        assert_eq!(entries[0].model, None);
27406        assert_eq!(entries[0].message_count, 0);
27407        let encoded_entries = serde_json::to_string(&entries).unwrap();
27408        assert!(!encoded_entries.contains("stable-session"));
27409        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27410        let page_request = NodeRequest::PageNativeSessions {
27411            route: route.clone(),
27412            window: crate::protocol::NativeSessionCatalogWindow::Recent,
27413            catalog_revision: summary.catalog_revision,
27414            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27415            after_selection_id: None,
27416            limit: 2,
27417        };
27418        let observer = process_request_inner(
27419            &shared,
27420            7,
27421            ClientRole::Observer,
27422            page_request.clone(),
27423        )
27424        .await
27425        .unwrap_err();
27426        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27427        let page_response = process_request_inner(
27428            &shared,
27429            7,
27430            ClientRole::Operator,
27431            page_request.clone(),
27432        )
27433        .await
27434        .unwrap();
27435        assert!(matches!(
27436            page_response,
27437            NodeResponse::NativeSessionsPaged { page, .. }
27438                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27439        ));
27440        let stale = process_request_inner(
27441            &shared,
27442            7,
27443            ClientRole::Operator,
27444            NodeRequest::PageNativeSessions {
27445                route: route.clone(),
27446                window: crate::protocol::NativeSessionCatalogWindow::Recent,
27447                catalog_revision: summary.catalog_revision.wrapping_add(1),
27448                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27449                after_selection_id: None,
27450                limit: 2,
27451            },
27452        )
27453        .await
27454        .unwrap_err();
27455        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27456        let selection = NativeSessionSelection {
27457            route: route.clone(),
27458            catalog_revision: summary.catalog_revision,
27459            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27460            selection_id: entries[0].selection_id.clone(),
27461        };
27462        let preview_response = process_request_inner(
27463            &shared,
27464            7,
27465            ClientRole::Operator,
27466            NodeRequest::PreviewNativeSession {
27467                selection: selection.clone(),
27468                message_limit: 1,
27469            },
27470        )
27471        .await
27472        .unwrap();
27473        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27474            panic!("native preview returned a different response");
27475        };
27476        assert_eq!(preview.messages.len(), 1);
27477        assert!(preview.message_count_exact);
27478        assert_eq!(preview.messages[0].text, "raw answer");
27479        assert!(preview.truncated);
27480
27481        let index_request = NodeRequest::IndexNativeSession {
27482            selection: selection.clone(),
27483            display_name: "Saved Claude".to_owned(),
27484        };
27485        let observer = process_request_inner(
27486            &shared,
27487            7,
27488            ClientRole::Observer,
27489            index_request.clone(),
27490        )
27491        .await
27492        .unwrap_err();
27493        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27494        let without_controller = process_request_inner(
27495            &shared,
27496            7,
27497            ClientRole::Operator,
27498            index_request.clone(),
27499        )
27500        .await
27501        .unwrap_err();
27502        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27503        let acquired = process_request_inner(
27504            &shared,
27505            7,
27506            ClientRole::Operator,
27507            NodeRequest::AcquireController { lease_ms: 5_000 },
27508        )
27509        .await
27510        .unwrap();
27511        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27512        let indexed = process_request_inner(
27513            &shared,
27514            7,
27515            ClientRole::Operator,
27516            index_request,
27517        )
27518        .await
27519        .unwrap();
27520        assert!(indexed.native_session_index_contract_is_valid());
27521        let NodeResponse::NativeSessionIndexed {
27522            selection: echoed_selection,
27523            record,
27524        } = indexed else {
27525            panic!("native session index returned a different response");
27526        };
27527        assert_eq!(echoed_selection, selection);
27528        let recataloged = process_request_inner(
27529            &shared,
27530            7,
27531            ClientRole::Operator,
27532            NodeRequest::CatalogNativeSessions {
27533                route,
27534                limit: 2,
27535            },
27536        )
27537        .await
27538        .unwrap();
27539        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27540            panic!("native catalog returned a different response after indexing");
27541        };
27542        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27543
27544        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27545        let external_catalog = process_request_inner(
27546            &shared,
27547            7,
27548            ClientRole::Operator,
27549            NodeRequest::CatalogNativeSessions {
27550                route: unregistered_route.clone(),
27551                limit: 2,
27552            },
27553        )
27554        .await
27555        .unwrap();
27556        let external_json = serde_json::to_string(&external_catalog).unwrap();
27557        assert!(!external_json.contains("outside-session"));
27558        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27559        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27560        assert!(!external_json.contains("\"session_id\""));
27561        assert!(!external_json.contains("\"cwd\""));
27562        assert!(!external_json.contains("\"path\""));
27563        let NodeResponse::NativeSessionsCataloged {
27564            route: echoed_route,
27565            entries: external_entries,
27566            summary: Some(external_summary),
27567        } = external_catalog
27568        else {
27569            panic!("unregistered native catalog returned a different response");
27570        };
27571        assert_eq!(echoed_route, unregistered_route);
27572        assert_eq!(external_entries.len(), 1);
27573        assert_eq!(external_entries[0].record_id, None);
27574        let external_group = external_entries[0]
27575            .external_group
27576            .as_ref()
27577            .expect("unregistered session must have an external group");
27578        assert_eq!(
27579            external_group.kind,
27580            gate4agent_types::NativeSessionExternalGroupKind::Project,
27581        );
27582        assert!(external_group.group_id.starts_with("external-"));
27583        assert_eq!(
27584            external_group.display_name,
27585            outside_root.file_name().unwrap().to_string_lossy(),
27586        );
27587        let external_selection = NativeSessionSelection {
27588            route: unregistered_route,
27589            catalog_revision: external_summary.catalog_revision,
27590            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27591            selection_id: external_entries[0].selection_id.clone(),
27592        };
27593        let external_preview = process_request_inner(
27594            &shared,
27595            7,
27596            ClientRole::Operator,
27597            NodeRequest::PreviewNativeSession {
27598                selection: external_selection.clone(),
27599                message_limit: 1,
27600            },
27601        )
27602        .await
27603        .unwrap();
27604        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27605        assert!(!external_preview_json.contains("outside-session"));
27606        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27607        assert!(!external_preview_json.contains("\"session_id\""));
27608        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27609            panic!("unregistered native preview returned a different response");
27610        };
27611        assert_eq!(preview.messages.len(), 1);
27612        let external_index = process_request_inner(
27613            &shared,
27614            7,
27615            ClientRole::Operator,
27616            NodeRequest::IndexNativeSession {
27617                selection: external_selection,
27618                display_name: "External Claude".to_owned(),
27619            },
27620        )
27621        .await
27622        .unwrap_err();
27623        assert_eq!(
27624            external_index.code,
27625            NodeFailureCode::WorkspaceRegistrationRequired,
27626        );
27627        let record_response = process_request_inner(
27628            &shared,
27629            7,
27630            ClientRole::Operator,
27631            NodeRequest::PreviewSessionRecord {
27632                record_id: record.record_id.clone(),
27633                message_limit: 2,
27634            },
27635        )
27636        .await
27637        .unwrap();
27638        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27639            panic!("record preview returned a different response");
27640        };
27641        assert_eq!(record_id, record.record_id);
27642        assert_eq!(preview.messages.len(), 2);
27643        let encoded = serde_json::to_string(&preview).unwrap();
27644        assert!(!encoded.contains("stable-session"));
27645        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27646        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27647            panic!("record preview history summary returned a different response");
27648        };
27649        let summaries = events
27650            .iter()
27651            .filter_map(|envelope| match &envelope.event {
27652                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27653                    if record_id == &record.record_id =>
27654                {
27655                    Some(*summary)
27656                }
27657                _ => None,
27658            })
27659            .collect::<Vec<_>>();
27660        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27661        let summary_json = serde_json::to_string(&summaries).unwrap();
27662        for private in [
27663            "stable-session",
27664            "raw user text must not cross the catalog wire",
27665            "raw answer",
27666            "Workspace review",
27667            "claude-sonnet",
27668        ] {
27669            assert!(!summary_json.contains(private), "{private}");
27670        }
27671        let identity = record.provider_session.clone().unwrap();
27672        assert_eq!(
27673            shared.revalidate_session_record_preview(&record, &identity),
27674            Ok(())
27675        );
27676        shared.remove_record_memory(&record.record_id);
27677        assert_eq!(
27678            shared
27679                .revalidate_session_record_preview(&record, &identity)
27680                .unwrap_err()
27681                .code,
27682            NodeFailureCode::UnknownSessionRecord
27683        );
27684        let released = process_request_inner(
27685            &shared,
27686            7,
27687            ClientRole::Operator,
27688            NodeRequest::ReleaseController,
27689        )
27690        .await
27691        .unwrap();
27692        assert!(matches!(released, NodeResponse::Controller { controller: None }));
27693        assert!(shared.controller_state().is_none());
27694        std::fs::remove_dir_all(workspace_root).unwrap();
27695        std::fs::remove_dir_all(history_root).unwrap();
27696        std::fs::remove_dir_all(outside_root).unwrap();
27697    }
27698
27699    #[tokio::test]
27700    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27701        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27702        std::fs::create_dir_all(&history_root).unwrap();
27703        let history = NativeHistoryConfig::new(vec![
27704            NativeHistoryRoot::new(
27705                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27706                HistorySourceLayout::SingleNdjson,
27707                history_root.clone(),
27708            )
27709            .unwrap(),
27710        ])
27711        .unwrap();
27712        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27713        let held = catalog.lock().unwrap();
27714        let result = timeout(
27715            Duration::from_secs(1),
27716            run_native_session_catalog_operation(
27717                Arc::clone(&catalog),
27718                "native session catalog",
27719                |_| Ok::<(), std::convert::Infallible>(()),
27720            ),
27721        )
27722        .await
27723        .expect("concurrent native catalog request must not wait for the operation deadline")
27724        .unwrap_err();
27725        assert_eq!(result.code, NodeFailureCode::BackendBusy);
27726        drop(held);
27727        std::fs::remove_dir_all(history_root).unwrap();
27728    }
27729
27730    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27731        let unique = std::time::SystemTime::now()
27732            .duration_since(std::time::UNIX_EPOCH)
27733            .unwrap()
27734            .as_nanos();
27735        std::env::temp_dir().join(format!(
27736            "gate4agent-node-{label}-{}-{unique}",
27737            std::process::id(),
27738        ))
27739    }
27740
27741    fn git_snapshot_for_parser() -> GitSnapshot {
27742        GitSnapshot {
27743            is_repository: true,
27744            branch: Some("main".to_owned()),
27745            status: Vec::new(),
27746            recent_commits: Vec::new(),
27747            worktrees: Vec::new(),
27748            managed_worktree: None,
27749            truncated: false,
27750            diagnostic: None,
27751        }
27752    }
27753
27754    #[tokio::test]
27755    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27756        let root = temporary_workspace_root("managed-git-scope");
27757        std::fs::create_dir_all(&root).unwrap();
27758        run_test_git(&root, &["init"]);
27759        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27760        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27761        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27762        run_test_git(&root, &["add", "seed.txt"]);
27763        run_test_git(&root, &["commit", "-m", "seed"]);
27764        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27765
27766        let workspace_id = WorkspaceId::new("managed-a").unwrap();
27767        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27768        let catalog = active_registry().unwrap();
27769        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27770        let shared = NodeShared::new(
27771            handle,
27772            "fixture-token".to_owned(),
27773            NodeId::new("node-1").unwrap(),
27774            vec![workspace],
27775            vec![agent("claude")],
27776        );
27777        let mut lease = ManagedWorktreeLeaseRecord {
27778            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27779            source_workspace_id: WorkspaceId::new("primary").unwrap(),
27780            workspace_id: workspace_id.clone(),
27781            profile_id: WorktreeProfileId::new("default").unwrap(),
27782            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27783            target_root: root.to_string_lossy().into_owned(),
27784            branch: "gate4agent/a".to_owned(),
27785            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27786            expected_head: None,
27787            retention: ManagedWorktreeRetention::Retain,
27788            state: ManagedWorktreeLeaseState::InUse,
27789            session_holders: vec![ManagedWorktreeSessionHolder {
27790                incarnation_id: shared.incarnation_id,
27791                instance_id: AgentInstanceId(7),
27792                generation: SessionGeneration(1),
27793            }],
27794            record_holders: Vec::new(),
27795            cleanup_failure: None,
27796            created_at_unix_ms: 1,
27797            updated_at_unix_ms: 1,
27798        };
27799        *shared
27800            .managed_worktrees
27801            .lock()
27802            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27803            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27804
27805        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27806        let scope = inspection.git.managed_worktree.unwrap();
27807        assert_eq!(scope.lease_id, lease.lease_id);
27808        assert_eq!(scope.branch, "gate4agent/a");
27809        assert_eq!(scope.active_session_count, 1);
27810
27811        lease.branch = "gate4agent/b".to_owned();
27812        *shared
27813            .managed_worktrees
27814            .lock()
27815            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27816            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27817        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27818        assert!(mismatch.git.managed_worktree.is_none());
27819        std::fs::remove_dir_all(root).unwrap();
27820    }
27821
27822    #[tokio::test]
27823    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27824        let root = temporary_workspace_root("workspace-file-write-controller");
27825        std::fs::create_dir_all(root.join("src")).unwrap();
27826        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27827        let workspace_id = WorkspaceId::new("primary").unwrap();
27828        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27829        let catalog = active_registry().unwrap();
27830        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27831        let shared = NodeShared::new(
27832            handle,
27833            "fixture-token".to_owned(),
27834            NodeId::new("node-1").unwrap(),
27835            vec![workspace],
27836            vec![agent("claude")],
27837        );
27838        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27839        let expected_revision = workspace_file_revision(b"old\n");
27840        let request = NodeRequest::WriteWorkspaceFile {
27841            workspace_id: workspace_id.clone(),
27842            path: path.clone(),
27843            expected_revision: expected_revision.clone(),
27844            text: "new\n".to_owned(),
27845        };
27846        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27847            .await
27848            .unwrap_err();
27849        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27850        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27851            .await
27852            .unwrap_err();
27853        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27854        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27855        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27856        let NodeResponse::WorkspaceFileWritten { file } = response else {
27857            panic!("workspace file write returned another response");
27858        };
27859        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27860        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27861        let stale = process_request_inner(
27862            &shared,
27863            77,
27864            ClientRole::Operator,
27865            NodeRequest::WriteWorkspaceFile {
27866                workspace_id,
27867                path,
27868                expected_revision,
27869                text: "lost update\n".to_owned(),
27870            },
27871        )
27872        .await
27873        .unwrap_err();
27874        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27875        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27876        std::fs::remove_dir_all(root).unwrap();
27877    }
27878
27879    #[tokio::test]
27880    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27881        let root = temporary_workspace_root("workspace-entry-create-controller");
27882        std::fs::create_dir_all(root.join("src")).unwrap();
27883        let workspace_id = WorkspaceId::new("primary").unwrap();
27884        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27885        let catalog = active_registry().unwrap();
27886        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27887        let shared = NodeShared::new(
27888            handle,
27889            "fixture-token".to_owned(),
27890            NodeId::new("node-1").unwrap(),
27891            vec![workspace],
27892            vec![agent("claude")],
27893        );
27894        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27895        let request = NodeRequest::CreateWorkspaceFile {
27896            workspace_id: workspace_id.clone(),
27897            path: path.clone(),
27898        };
27899        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27900            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27901        }));
27902        assert!(!request_uses_unnegotiated_capability(
27903            &request,
27904            &baseline_capabilities().unwrap(),
27905        ));
27906        assert!(request_uses_unnegotiated_capability(&request, &[]));
27907        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27908            .await
27909            .unwrap_err();
27910        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27911        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27912            .await
27913            .unwrap_err();
27914        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27915        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27916
27917        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27918            .await
27919            .unwrap();
27920        let NodeResponse::WorkspaceFileCreated { file } = response else {
27921            panic!("workspace file create returned another response");
27922        };
27923        assert_eq!(file.workspace_id, workspace_id);
27924        assert_eq!(file.path, path);
27925        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27926        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27927
27928        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27929            .await
27930            .unwrap_err();
27931        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27932
27933        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27934        let response = process_request_inner(
27935            &shared,
27936            77,
27937            ClientRole::Operator,
27938            NodeRequest::CreateWorkspaceDirectory {
27939                workspace_id: workspace_id.clone(),
27940                path: directory_path.clone(),
27941            },
27942        )
27943        .await
27944        .unwrap();
27945        assert_eq!(
27946            response,
27947            NodeResponse::WorkspaceDirectoryCreated {
27948                workspace_id: workspace_id.clone(),
27949                entry: WorkspaceEntry {
27950                    relative_path: directory_path,
27951                    kind: WorkspaceEntryKind::Directory,
27952                },
27953            },
27954        );
27955        assert!(root.join("src/nested").is_dir());
27956
27957        let missing_parent = process_request_inner(
27958            &shared,
27959            77,
27960            ClientRole::Operator,
27961            NodeRequest::CreateWorkspaceFile {
27962                workspace_id: workspace_id.clone(),
27963                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27964            },
27965        )
27966        .await
27967        .unwrap_err();
27968        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27969        let non_directory_parent = process_request_inner(
27970            &shared,
27971            77,
27972            ClientRole::Operator,
27973            NodeRequest::CreateWorkspaceFile {
27974                workspace_id,
27975                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27976            },
27977        )
27978        .await
27979        .unwrap_err();
27980        assert_eq!(
27981            non_directory_parent.code,
27982            NodeFailureCode::RepositoryParentNotDirectory,
27983        );
27984        std::fs::remove_dir_all(root).unwrap();
27985    }
27986
27987    #[tokio::test]
27988    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27989        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27990        std::fs::create_dir_all(root.join("src")).unwrap();
27991        #[cfg(windows)]
27992        let root = std::fs::canonicalize(root).unwrap();
27993        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27994        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27995        let barrier = Arc::new(std::sync::Barrier::new(2));
27996        let done = Arc::new(AtomicBool::new(false));
27997        let task_commit_state = Arc::clone(&commit_state);
27998        let task_barrier = Arc::clone(&barrier);
27999        let task_done = Arc::clone(&done);
28000        let task_root = root.clone();
28001        let task = tokio::task::spawn_blocking(move || {
28002            task_barrier.wait();
28003            let result = create_workspace_file_on_disk(
28004                &task_root,
28005                &path,
28006                &task_commit_state,
28007            );
28008            task_done.store(true, Ordering::Release);
28009            result
28010        });
28011
28012        let error = settle_workspace_entry_create(
28013            task,
28014            Arc::clone(&commit_state),
28015            Duration::from_millis(10),
28016            "fixture deadline",
28017            "fixture task failed",
28018        )
28019        .await
28020        .unwrap_err();
28021        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28022        assert_eq!(
28023            commit_state.load(Ordering::Acquire),
28024            WORKSPACE_ENTRY_CREATE_CANCELED,
28025        );
28026
28027        barrier.wait();
28028        timeout(Duration::from_secs(1), async {
28029            while !done.load(Ordering::Acquire) {
28030                tokio::task::yield_now().await;
28031            }
28032        })
28033        .await
28034        .expect("canceled blocking create did not settle");
28035        assert!(!root.join("src/late.rs").exists());
28036        std::fs::remove_dir_all(root).unwrap();
28037    }
28038}
28039
28040#[cfg(test)]
28041mod provisional_spawn_tests {
28042    use super::*;
28043    use std::sync::Arc;
28044
28045    fn shared() -> NodeShared {
28046        let catalog = active_registry().unwrap();
28047        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28048        let workspace = WorkspaceConfig::new(
28049            WorkspaceId::new("primary").unwrap(),
28050            std::env::current_dir().unwrap(),
28051        )
28052        .unwrap()
28053        .with_worktree_service_mode(WorktreeServiceMode::Manual);
28054        NodeShared::new(
28055            handle,
28056            "fixture-token".to_owned(),
28057            NodeId::new("node-provisional-test").unwrap(),
28058            vec![workspace],
28059            vec![AgentId::new("claude").unwrap()],
28060        )
28061    }
28062
28063    /// Provisional draft map: sessionize by instance id only; 50 sequential
28064    /// mark/claim cycles never collide, and a claimed draft is never reaped.
28065    #[test]
28066    fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28067        let shared = shared();
28068        let ws = WorkspaceId::new("primary").unwrap();
28069        let mut ids = Vec::with_capacity(50);
28070        for i in 1u64..=50 {
28071            let id = AgentInstanceId(i);
28072            ids.push(id);
28073            let address = SessionAddress {
28074                workspace_id: ws.clone(),
28075                session: SessionKey {
28076                    instance_id: id,
28077                    generation: SessionGeneration(1),
28078                },
28079            };
28080            shared.mark_spawn_provisional(address);
28081        }
28082        assert_eq!(
28083            shared
28084                .provisional_spawns
28085                .lock()
28086                .unwrap_or_else(|p| p.into_inner())
28087                .len(),
28088            50
28089        );
28090        for id in ids.iter().step_by(2) {
28091            shared.sessionize_spawn(*id);
28092        }
28093        assert_eq!(
28094            shared
28095                .provisional_spawns
28096                .lock()
28097                .unwrap_or_else(|p| p.into_inner())
28098                .len(),
28099            25
28100        );
28101        assert!(shared.take_due_provisional_spawns().is_empty());
28102        {
28103            let mut map = shared
28104                .provisional_spawns
28105                .lock()
28106                .unwrap_or_else(|p| p.into_inner());
28107            let past = Instant::now() - Duration::from_secs(30);
28108            for (_, (_, at)) in map.iter_mut() {
28109                *at = past;
28110            }
28111        }
28112        let due = shared.take_due_provisional_spawns();
28113        assert_eq!(due.len(), 25);
28114        let due_ids: std::collections::BTreeSet<_> =
28115            due.iter().map(|a| a.session.instance_id.0).collect();
28116        let expected: std::collections::BTreeSet<_> =
28117            ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28118        assert_eq!(due_ids, expected);
28119        assert!(shared
28120            .provisional_spawns
28121            .lock()
28122            .unwrap_or_else(|p| p.into_inner())
28123            .is_empty());
28124    }
28125
28126    /// Twenty parallel provisional marks must each get their own instance key;
28127    /// killing/sessionizing one id must not touch the others.
28128    #[test]
28129    fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28130        let shared = Arc::new(shared());
28131        let ws = WorkspaceId::new("primary").unwrap();
28132        let mut handles = Vec::new();
28133        for i in 1u64..=20 {
28134            let shared = Arc::clone(&shared);
28135            let ws = ws.clone();
28136            handles.push(std::thread::spawn(move || {
28137                let id = AgentInstanceId(i);
28138                let address = SessionAddress {
28139                    workspace_id: ws,
28140                    session: SessionKey {
28141                        instance_id: id,
28142                        generation: SessionGeneration(1),
28143                    },
28144                };
28145                shared.mark_spawn_provisional(address);
28146                id
28147            }));
28148        }
28149        let mut ids = Vec::new();
28150        for h in handles {
28151            ids.push(h.join().unwrap());
28152        }
28153        ids.sort_by_key(|id| id.0);
28154        assert_eq!(ids.len(), 20);
28155        ids.dedup();
28156        assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28157        assert_eq!(
28158            shared
28159                .provisional_spawns
28160                .lock()
28161                .unwrap_or_else(|p| p.into_inner())
28162                .len(),
28163            20
28164        );
28165        shared.sessionize_spawn(AgentInstanceId(7));
28166        let left: Vec<_> = shared
28167            .provisional_spawns
28168            .lock()
28169            .unwrap_or_else(|p| p.into_inner())
28170            .keys()
28171            .map(|id| id.0)
28172            .collect();
28173        assert!(!left.contains(&7));
28174        assert_eq!(left.len(), 19);
28175    }
28176}
28177
28178
28179#[cfg(unix)]
28180extern "C" {
28181    #[link_name = "geteuid"]
28182    fn libc_geteuid() -> u32;
28183}
28184
28185#[cfg(test)]
28186mod call_home_link_tests {
28187    use super::*;
28188
28189    #[test]
28190    fn call_home_backoff_is_capped_and_monotonic() {
28191        let mut last = Duration::ZERO;
28192        for attempt in 1..20 {
28193            let next = call_home_backoff(attempt);
28194            assert!(next >= last);
28195            assert!(next <= Duration::from_secs(8));
28196            last = next;
28197        }
28198        assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28199    }
28200
28201    #[tokio::test]
28202    async fn call_home_socket_gets_keepalive() {
28203        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28204        let addr = listener.local_addr().unwrap();
28205        let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28206        let client = client.unwrap();
28207        enable_call_home_keepalive(&client).unwrap();
28208        let sock = socket2::SockRef::from(&client);
28209        assert!(sock.keepalive().unwrap());
28210        #[cfg(any(target_os = "linux", target_os = "macos"))]
28211        assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28212    }
28213}