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    /// Per-session grok leader sockets (never the operator's shared leader).
3220    grok_leaders: Mutex<BTreeMap<AgentInstanceId, std::path::PathBuf>>,
3221    /// m4a signer proxy (option A): when set, PTY sessions get a process-bound handle
3222    /// instead of a bearer capability.
3223    #[cfg(unix)]
3224    signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3225    materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3226    spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3227    managed_spawn_replays:
3228        Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3229    controller: Mutex<Option<ControllerLease>>,
3230    /// Fresh spawns waiting for their first successful HQ attach. Removed on
3231    /// Resize/TerminalBytes (sessionize) or when the grace reaper stops them.
3232    provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3233    /// ACP launch options waiting for the agent to advertise its config options.
3234    pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3235    history: Mutex<NodeEventHistory>,
3236    event_tx: broadcast::Sender<NodeEventEnvelope>,
3237    terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3238    /// The outbound `NodeEvent::AgentStream` push channel -- its own
3239    /// dedicated, capability-gated broadcast, mirroring `terminal_event_tx`
3240    /// exactly rather than riding the durable `event_tx` history (see
3241    /// `publish`'s own assertion): a subscriber who never negotiated
3242    /// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` must never receive one of
3243    /// these, the same guarantee `include_terminal_frame_events` already
3244    /// gives terminal frames.
3245    agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3246    /// The outbound `NodeEvent::HarnessMcpReadCall` push channel -- mirrors
3247    /// `agent_stream_event_tx` exactly, its own dedicated, capability-gated
3248    /// broadcast rather than a ride on the durable `event_tx` history. A
3249    /// `HarnessMcpReadCall` is a request/response call a live proxy call is
3250    /// waiting on (`harness_mcp_proxy.rs`'s `begin_call`); the shared bus's
3251    /// finite capacity and its willingness to silently evict a lagging
3252    /// subscriber's backlog is exactly how the call vanished with neither
3253    /// side ever logging it (see `publish_transient`'s own doc).
3254    harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3255    terminal_frame_watermarks:
3256        Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3257    /// Wakes the drive loop as soon as PTY input is queued (no 10ms nap).
3258    pty_input_wake: tokio::sync::Notify,
3259    /// Per session: when the last PTY input was dispatched (latency probe +
3260    /// a short fast-poll window so the echo is published within ~1ms).
3261    pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3262    next_connection_id: AtomicU64,
3263    next_instance_id: AtomicU64,
3264    next_command_id: AtomicU64,
3265    shutdown: AtomicBool,
3266    shutdown_notify: Notify,
3267    preauth_slots: Arc<Semaphore>,
3268    authenticated_slots: Arc<Semaphore>,
3269    inspection_slots: Arc<Semaphore>,
3270    mutation_gate: AsyncMutex<()>,
3271    session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3272    session_records: Mutex<SessionRecords>,
3273    state_path: Option<PathBuf>,
3274    persistence_error: RwLock<Option<String>>,
3275    state_transaction: Mutex<()>,
3276    input_settle_timeout_ms: u64,
3277    /// Drive-loop phase/rate timings -- see `drive_loop_profile`'s own doc
3278    /// comment. Written once per iteration by
3279    /// `drive_runtime_until_shutdown`, read by `GET /metrics`.
3280    drive_loop_profile: Mutex<DriveLoopProfiler>,
3281    /// Latest copy of `NativeRuntime::tick_profile_snapshot()` -- `Copy`,
3282    /// so an overwrite each iteration is cheap. The runtime itself is not
3283    /// reachable from here (it is owned locally by
3284    /// `drive_runtime_until_shutdown`), so this is how its own per-phase
3285    /// tick timings reach `GET /metrics`.
3286    runtime_tick_profile: Mutex<TickProfileSnapshot>,
3287    /// The live shell-efficiency profile, shared with the per-instance
3288    /// worker loops that fold into it.
3289    ///
3290    /// Deliberately NOT the per-iteration snapshot copy `runtime_tick_
3291    /// profile` above keeps: producing a snapshot sorts every ring behind
3292    /// it, and that sort belongs on the read. A `OnceLock` rather than a
3293    /// field set at construction because `NodeShared` is built before the
3294    /// `NativeRuntime` that owns the profile exists -- the drive loop
3295    /// publishes it once, before its first iteration. Unset means the
3296    /// runtime has not started driving yet, which `GET /metrics` reports as
3297    /// empty series rather than as zeroes it never measured.
3298    shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3299    /// Physical PTY sessions the native runtime currently owns
3300    /// (`NativeRuntime::active_native_sessions`), refreshed once per drive
3301    /// loop iteration -- deliberately a separate count from
3302    /// `handle.snapshot().sessions.len()` (the control-plane kernel's own
3303    /// session bookkeeping), since the two can disagree and the gap is
3304    /// itself a diagnostic signal `GET /metrics` exposes.
3305    native_session_gauge: AtomicUsize,
3306    /// Lifetime totals for the per-connection serve loop, summed across
3307    /// every live connection.
3308    ///
3309    /// The drive loop's own phase timings account for about ten
3310    /// microseconds an iteration on an idle node, yet the process burns a
3311    /// quarter of a core — and burns it ONLY while a c2 is attached (0.5%
3312    /// with none, 23% with two, measured). That gap is somewhere in
3313    /// serving a connection, and there was no way to see it: iterations
3314    /// says whether the loop is spinning rather than sleeping, and events
3315    /// says whether it is spinning over real work or over nothing.
3316    connection_loop_iterations: AtomicU64,
3317    connection_events_sent: AtomicU64,
3318    /// Lifetime totals for `drive_runtime_until_shutdown`'s own iterations
3319    /// -- `drive_loop_iterations_idle` counts iterations that applied zero
3320    /// `NativeRuntime::tick` observations AND forwarded zero control
3321    /// events; see the exact "produced nothing" definition at the site in
3322    /// `drive_runtime_until_shutdown` that increments these. Read twice
3323    /// and divide by the interval, same rule as `connections` above --
3324    /// `idle / total` is backlog item 5's "wake on work" number.
3325    /// Lifetime count of control commands the kernel refused.
3326    rejected_commands_total: AtomicU64,
3327    /// The named reason behind a recent kernel rejection, by the
3328    /// `CommandId` `dispatch_bounded` handed back when it enqueued that
3329    /// command -- populated by `drive_runtime_until_shutdown` for every
3330    /// `Err` in a tick's `command_outcomes`, and drained by whichever
3331    /// spawn-dispatch waiter recognizes its own `Register`/`Start`
3332    /// `CommandId` among them.
3333    ///
3334    /// A `ControlCommand` only ever fails synchronously (a `NodeFailure`
3335    /// out of `dispatch`/`dispatch_bounded` itself) for a port-level ingress
3336    /// problem -- full or disconnected -- never for the kernel refusing the
3337    /// command's content (an unsupported transport, an unknown agent, ...);
3338    /// that verdict exists only once `NativeRuntime::tick` has processed the
3339    /// command, one drive-loop iteration later, in a different task than
3340    /// whichever caller is awaiting the spawn. Without this map, a spawn
3341    /// wait loop that polls `self.handle.snapshot()` for a session that
3342    /// will now never appear has no way to learn why -- it can only spin
3343    /// until its own commit deadline and report a generic "did not commit"
3344    /// timeout, discarding a reason the kernel already named precisely (see
3345    /// `KernelCommandError`'s variants, e.g. `UnsupportedTransport`).
3346    rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3347    drive_loop_iterations_total: AtomicU64,
3348    drive_loop_iterations_idle: AtomicU64,
3349    /// The operator-chosen age/keep-N policy the retention sweep applies to
3350    /// `ManagedSessionState::Unavailable` records. Both `0` by default --
3351    /// see `SessionRecordRetentionConfig`'s own doc comment.
3352    session_record_retention: SessionRecordRetentionConfig,
3353    /// Lifetime count of managed session records the retention sweep has
3354    /// retired. Same tier as `rejected_commands_total` above: a live,
3355    /// process-lifetime instrument, never persisted and never reloaded from
3356    /// `state-v1.json` -- a restart legitimately resets it, matching every
3357    /// other `*_total` counter here.
3358    retired_records_total: AtomicU64,
3359    #[cfg(feature = "fixture")]
3360    fixture_semantic_hook_policy: bool,
3361    #[cfg(feature = "fixture")]
3362    fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3363}
3364
3365impl Drop for NodeShared {
3366    fn drop(&mut self) {
3367        SessionLifecycleHook::on_shutdown(self);
3368    }
3369}
3370
3371#[derive(Clone, Debug, Eq, PartialEq)]
3372struct SessionBinding {
3373    workspace_id: WorkspaceId,
3374    generation: SessionGeneration,
3375    runtime_policy: ProviderRuntimePolicy,
3376    pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3377    record_id: Option<SessionRecordId>,
3378    managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3379    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3380    bundle: Option<ResolvedBundleReceipt>,
3381    context: Option<ResolvedContextPackReceipt>,
3382    materialization_id: Option<MaterializationId>,
3383}
3384
3385#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3386enum SpawnRecordPolicy {
3387    ProviderIdentityOnly,
3388    Always,
3389}
3390
3391struct NativeEnvironmentSelectionGuard {
3392    control: Option<NativeLaunchProfileControl>,
3393    instance_id: AgentInstanceId,
3394}
3395
3396struct NativeInstanceOverlayGuard {
3397    control: Option<NativeLaunchProfileControl>,
3398    instance_id: AgentInstanceId,
3399}
3400
3401struct NativeHarnessMcpOverlayGuard {
3402    control: NativeLaunchProfileControl,
3403    instance_id: AgentInstanceId,
3404}
3405
3406impl NativeHarnessMcpOverlayGuard {
3407    fn retain(self) { std::mem::forget(self); }
3408}
3409
3410impl Drop for NativeHarnessMcpOverlayGuard {
3411    fn drop(&mut self) {
3412        self.control
3413            .clear_native_mcp_server_launch_overlay(self.instance_id);
3414    }
3415}
3416
3417impl NativeInstanceOverlayGuard {
3418    fn retain(mut self) {
3419        self.control = None;
3420    }
3421}
3422
3423impl Drop for NativeInstanceOverlayGuard {
3424    fn drop(&mut self) {
3425        if let Some(control) = self.control.as_ref() {
3426            control.clear_native_instance_launch_overlay(self.instance_id);
3427        }
3428    }
3429}
3430
3431enum PreparedNativeLaunchOverlay {
3432    Environment(NativeLaunchEnvironmentOverlay),
3433    Instance(NativeInstanceLaunchOverlay),
3434}
3435
3436impl NativeEnvironmentSelectionGuard {
3437    fn retain(mut self) {
3438        self.control = None;
3439    }
3440}
3441
3442impl Drop for NativeEnvironmentSelectionGuard {
3443    fn drop(&mut self) {
3444        if let Some(control) = self.control.as_ref() {
3445            control.clear_native_launch_profile_selection(self.instance_id);
3446        }
3447    }
3448}
3449
3450struct SessionMaterializationGuard<'a> {
3451    shared: &'a NodeShared,
3452    id: Option<MaterializationId>,
3453    /// Absolute ProviderHome (`home/`) under the session materialization root.
3454    /// Used for Claude station network `--settings` sidecar writes.
3455    provider_home: Option<PathBuf>,
3456}
3457
3458impl SessionMaterializationGuard<'_> {
3459    fn id(&self) -> Option<&MaterializationId> {
3460        self.id.as_ref()
3461    }
3462
3463    fn provider_home(&self) -> Option<&Path> {
3464        self.provider_home.as_deref()
3465    }
3466
3467    fn retain(mut self) {
3468        self.id = None;
3469    }
3470}
3471
3472impl Drop for SessionMaterializationGuard<'_> {
3473    fn drop(&mut self) {
3474        if let Some(id) = self.id.take() {
3475            let _ = self.shared.cleanup_materialization(&id);
3476        }
3477    }
3478}
3479
3480
3481/// Port: product-side reactions to session lifecycle transitions
3482/// (station lease, harness MCP overlay/registry, environment launch overlays).
3483trait SessionLifecycleHook {
3484    /// A binding was removed (unbind or spawn rollback).
3485    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3486    /// A session reached a terminal control event that revokes its harness MCP door.
3487    fn on_session_ended(&self, address: &SessionAddress);
3488    /// Node shutdown (or drop): revoke every harness MCP reservation.
3489    fn on_shutdown(&self);
3490    /// A harness MCP reservation was aborted before activation.
3491    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3492    /// Release session-owned materialization for a removed binding.
3493    fn cleanup_session_materialization(
3494        &self,
3495        address: &SessionAddress,
3496        binding: &SessionBinding,
3497    ) -> Result<(), NodeFailure>;
3498}
3499
3500impl SessionLifecycleHook for NodeShared {
3501    fn on_session_ended(&self, address: &SessionAddress) {
3502        self.retire_session_grok_leader(address.session.instance_id);
3503        if let Some(accounts) = self.m4a_accounts.as_ref() {
3504            let instance = address.session.instance_id;
3505            let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3506            for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3507                accounts.remove_session_file(nick);
3508            }
3509        }
3510        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3511            registry.revoke_session(address);
3512        }
3513        if let Some(control) = self.native_launch_profile_control.as_ref() {
3514            control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3515        }
3516    }
3517
3518    fn on_shutdown(&self) {
3519        let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3520            .into_values()
3521            .collect();
3522        for socket in leaders {
3523            crate::grok_leader::retire(&socket);
3524        }
3525        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3526            let instances = registry.shutdown();
3527            if let Some(control) = self.native_launch_profile_control.as_ref() {
3528                for instance_id in instances {
3529                    control.clear_native_mcp_server_launch_overlay(instance_id);
3530                }
3531            }
3532        }
3533    }
3534
3535    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3536        if let Some(control) = self.native_launch_profile_control.as_ref() {
3537            control.clear_native_mcp_server_launch_overlay(instance_id);
3538        }
3539    }
3540
3541    fn cleanup_session_materialization(
3542        &self,
3543        address: &SessionAddress,
3544        binding: &SessionBinding,
3545    ) -> Result<(), NodeFailure> {
3546        self.cleanup_session_owned_materialization(address, binding)
3547    }
3548
3549    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3550        // Exclusive dig2browser station profile lease is node-local; clear
3551        // on every session unbind / rollback. Never ImportSession/cookies.
3552        #[cfg(feature = "dig2-station-probe")]
3553        {
3554            crate::dig2_station_lease::release_lease_for_holder(
3555                &self.browser_station_leases,
3556                address.session.instance_id,
3557            );
3558        }
3559        if let Some(control) = self.native_launch_profile_control.as_ref() {
3560            control.clear_native_mcp_server_launch_overlay(
3561                address.session.instance_id,
3562            );
3563        }
3564        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3565            registry.revoke_session(address);
3566        }
3567        if removed
3568            .is_some_and(|binding| {
3569                binding.environment_profile.is_some()
3570                    || binding.bundle.is_some()
3571                    || binding.context.is_some()
3572            })
3573        {
3574            if let Some(control) = self.native_launch_profile_control.as_ref() {
3575                control.clear_native_launch_profile_selection(
3576                    address.session.instance_id,
3577                );
3578                control.clear_native_instance_launch_overlay(
3579                    address.session.instance_id,
3580                );
3581            }
3582        }
3583    }
3584}
3585
3586struct SpawnPrepareRequest<'r> {
3587    instance_id: AgentInstanceId,
3588    address: &'r SessionAddress,
3589    provider: &'r AgentId,
3590    mode: SessionMode,
3591    environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3592    bundle: Option<&'r ResolvedBundleReceipt>,
3593    context: Option<&'r ResolvedContextPackReceipt>,
3594    managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3595    harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3596    approval_level: ApprovalLevel,
3597    network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3598    browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3599}
3600
3601/// Guards held between "session identity minted" and "launch committed".
3602/// Field order is the drop order and matches the pre-refactor local order
3603/// (instance overlay, environment selection, materialization, harness, station lease).
3604struct PreparedSpawn<'a> {
3605    instance_overlay: Option<NativeInstanceOverlayGuard>,
3606    environment_selection: Option<NativeEnvironmentSelectionGuard>,
3607    materialization_guard: Option<SessionMaterializationGuard<'a>>,
3608    harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3609    #[cfg(feature = "dig2-station-probe")]
3610    browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3611    materialization_id: Option<MaterializationId>,
3612}
3613
3614impl PreparedSpawn<'_> {
3615    /// Session-owned launch state now belongs to the binding.
3616    fn retain_session_guards(&mut self) {
3617        if let Some(selection) = self.environment_selection.take() {
3618            selection.retain();
3619        }
3620        if let Some(overlay) = self.instance_overlay.take() {
3621            overlay.retain();
3622        }
3623        if let Some(guard) = self.materialization_guard.take() {
3624            guard.retain();
3625        }
3626    }
3627
3628    /// Launch committed: keep harness overlay and station lease for the session lifetime.
3629    fn retain_launch_guards(&mut self) {
3630        if let Some(overlay) = self.harness_mcp_overlay.take() {
3631            overlay.retain();
3632        }
3633        #[cfg(feature = "dig2-station-probe")]
3634        if let Some(guard) = self.browser_station_lease_guard.take() {
3635            guard.retain();
3636        }
3637    }
3638}
3639
3640/// Port: everything product-side that must be prepared before a session is
3641/// bound and started (station lease, harness MCP overlay, materialization,
3642/// environment selection, network overlays, launch overlay).
3643trait SpawnPreparer {
3644    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3645    /// Select the native launch profile for an environment-profile session.
3646    fn select_environment(
3647        &self,
3648        instance_id: AgentInstanceId,
3649        provider: &AgentId,
3650        mode: SessionMode,
3651        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3652    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3653    /// Install a prepared launch overlay for the instance.
3654    fn install_launch_overlay(
3655        &self,
3656        instance_id: AgentInstanceId,
3657        overlay: PreparedNativeLaunchOverlay,
3658    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3659}
3660
3661impl SpawnPreparer for NodeShared {
3662    fn select_environment(
3663        &self,
3664        instance_id: AgentInstanceId,
3665        provider: &AgentId,
3666        mode: SessionMode,
3667        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3668    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3669        self.select_environment_profile(instance_id, provider, mode, environment_profile)
3670    }
3671
3672    fn install_launch_overlay(
3673        &self,
3674        instance_id: AgentInstanceId,
3675        overlay: PreparedNativeLaunchOverlay,
3676    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3677        self.install_prepared_launch_overlay(instance_id, overlay)
3678    }
3679
3680    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3681        let SpawnPrepareRequest {
3682            instance_id,
3683            address,
3684            provider,
3685            mode,
3686            environment_profile,
3687            bundle,
3688            context,
3689            managed_authority,
3690            harness_mcp,
3691            approval_level,
3692            network_allowlist,
3693            browser_profile_id,
3694        } = request;
3695        // Track A follow-on: exclusive node-local BrowserStationLease after
3696        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
3697        // never ImportSession / cookies on C2. Linux/feature-off: probe
3698        // already refused or stub; this block is cfg'd out when feature off.
3699        #[cfg(feature = "dig2-station-probe")]
3700        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3701            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3702            match acquire_lease(
3703                &self.browser_station_leases,
3704                profile_id,
3705                instance_id,
3706                unix_time_ms(),
3707            ) {
3708                Ok(guard) => Some(guard),
3709                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3710                    return Err(failure(
3711                        NodeFailureCode::BrowserStationProfileBusy,
3712                        "dig2browser station profile is already leased by another session",
3713                    ));
3714                }
3715                Err(BrowserStationLeaseError::CapacityExceeded) => {
3716                    return Err(failure(
3717                        NodeFailureCode::BrowserStationProfileBusy,
3718                        "dig2browser station profile lease capacity exhausted",
3719                    ));
3720                }
3721                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3722                    return Err(failure(
3723                        NodeFailureCode::BrowserStationProfileBusy,
3724                        "session already holds a dig2browser station profile lease",
3725                    ));
3726                }
3727            }
3728        } else {
3729            None
3730        };
3731        #[cfg(not(feature = "dig2-station-probe"))]
3732        let _ = browser_profile_id;
3733        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3734            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
3735            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
3736            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
3737            // both read the identical overlay resolved below, one as child
3738            // environment, the other translated into one stdio MCP server
3739            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
3740            // only, and nothing downstream reads a harness-MCP overlay for
3741            // that transport yet.
3742            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3743            {
3744                return Err(failure(
3745                    NodeFailureCode::BindingMismatch,
3746                    "harness MCP reservation does not match the exact PTY/ACP provider",
3747                ));
3748            }
3749            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3750            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3751                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3752            })?;
3753            // Every name below is the caller's, carried in the reservation's
3754            // launch description: which environment variables receive the
3755            // endpoint, token and helper program, what the MCP server is called,
3756            // which argv it gets and which stale variables are erased.
3757            let launch = prepared.launch();
3758            let mut env_entries = vec![
3759                (
3760                    OsString::from(launch.endpoint_env.as_str()),
3761                    prepared.endpoint().as_os_str().to_os_string(),
3762                ),
3763                (
3764                    OsString::from(launch.token_env.as_str()),
3765                    prepared.token().expose().into(),
3766                ),
3767                (
3768                    OsString::from(launch.program_env.as_str()),
3769                    prepared.helper_program().as_os_str().to_os_string(),
3770                ),
3771            ];
3772            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3773                env_entries.push(trace_entry);
3774            }
3775            if let Some(door) = crate::door::door_listen_addr() {
3776                env_entries.push((
3777                    OsString::from("HATCHERY_NODE_DOOR"),
3778                    OsString::from(door.to_string()),
3779                ));
3780                // Same key shape as HQ browser/draft ids:
3781                // `pty-{incarnation}-{workspace}-{instance}` — never reuse a
3782                // slot across node boots when the instance counter resets.
3783                let session_key = format!(
3784                    "pty-{}-{}-{}",
3785                    self.incarnation_id,
3786                    address.workspace_id.as_str(),
3787                    address.session.instance_id.0
3788                );
3789                env_entries.push((
3790                    OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3791                    OsString::from(session_key),
3792                ));
3793            }
3794            let overlay = NativeMcpServerLaunchOverlay::new(
3795                provider.clone(),
3796                launch.server_name.as_str(),
3797                prepared.helper_program().as_os_str().to_os_string(),
3798                launch.args.iter().map(OsString::from).collect(),
3799                env_entries,
3800                launch.scrub_env.iter().map(OsString::from).collect(),
3801            ).map_err(|_| failure(
3802                NodeFailureCode::HarnessMcpUnavailable,
3803                "harness MCP launch overlay is unavailable",
3804            ))?;
3805            control
3806                .install_native_mcp_server_launch_overlay(instance_id, overlay)
3807                .map_err(|_| failure(
3808                    NodeFailureCode::HarnessMcpUnavailable,
3809                    "harness MCP launch overlay could not be installed",
3810                ))?;
3811            // State-change line: the overlay is installed and will be read
3812            // back by the PTY child's environment or, for ACP,
3813            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
3814            // time -- no secrets here, only which instance/provider/mode
3815            // now carries a harness-MCP door.
3816            tracing::info!(
3817                node_id = %self.node_id,
3818                instance_id = ?instance_id,
3819                provider = %provider,
3820                mode = ?mode,
3821                "harness MCP overlay installed for spawn",
3822            );
3823            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
3824        } else {
3825            None
3826        };
3827        let prepared_materialization = self.prepare_session_materialization(
3828            &address,
3829            &provider,
3830            mode,
3831            environment_profile,
3832            bundle,
3833            context,
3834            managed_authority.cloned(),
3835        )?;
3836        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
3837            Some((overlay, guard)) => (Some(overlay), Some(guard)),
3838            None => (None, None),
3839        };
3840        let materialization_id = materialization_guard
3841            .as_ref()
3842            .and_then(SessionMaterializationGuard::id)
3843            .cloned();
3844        let mut environment_selection = SpawnPreparer::select_environment(self, 
3845            instance_id,
3846            &provider,
3847            mode,
3848            environment_profile,
3849        )?;
3850        // Station network overlays: Codex `-c network_access` and/or Claude
3851        // ProviderHome `--settings` sidecar (permits → allowedDomains).
3852        let provider_home = materialization_guard
3853            .as_ref()
3854            .and_then(SessionMaterializationGuard::provider_home);
3855        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
3856            &provider,
3857            mode,
3858            approval_level,
3859            network_allowlist,
3860            provider_home,
3861        )?;
3862        let mut network_overlay_args = network_overlay_args;
3863        if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
3864            network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
3865        }
3866        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
3867            (prepared, args) if args.is_empty() => prepared,
3868            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
3869                overlay.append_extra_args(args).map_err(|_| {
3870                    failure(
3871                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3872                        "network allowlist overlay could not merge with instance launch args",
3873                    )
3874                })?;
3875                Some(PreparedNativeLaunchOverlay::Instance(overlay))
3876            }
3877            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
3878                Some(PreparedNativeLaunchOverlay::Instance(
3879                    overlay
3880                        .into_instance_with_extra_args(args)
3881                        .map_err(|_| {
3882                            failure(
3883                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3884                                "network allowlist overlay could not merge with environment overlay",
3885                            )
3886                        })?,
3887                ))
3888            }
3889            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
3890                NativeInstanceLaunchOverlay::new(
3891                    provider.clone(),
3892                    match mode {
3893                        SessionMode::Pty => TransportKind::Pty,
3894                        SessionMode::Acp => TransportKind::Acp,
3895                        SessionMode::Inline => TransportKind::Pipe,
3896                    },
3897                    Vec::new(),
3898                    args,
3899                )
3900                .map_err(|_| {
3901                    failure(
3902                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3903                        "network allowlist overlay is invalid for this transport",
3904                    )
3905                })?,
3906            )),
3907        };
3908        // Per-session m4a account: own nick + capability for this PTY child only, on its own
3909        // overlay or appended to the overlay the spawn already carries.
3910        #[cfg(unix)]
3911        // `HATCHERY_M4A_SIGNER_SHELL=1` also gives operator shells an identity (live canaries).
3912        let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
3913            (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
3914        });
3915        #[cfg(unix)]
3916        let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
3917            (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
3918                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3919                let uid = unsafe { libc_geteuid() };
3920                let handle = proxy.reserve(&session_key, m4a_provider, uid);
3921                let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
3922                tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
3923                Some(vec![
3924                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
3925                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
3926                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
3927                ])
3928            }
3929            _ => None,
3930        };
3931        #[cfg(not(unix))]
3932        let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
3933        let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
3934            (SessionMode::Pty, _, _, Some(identity)) => {
3935                let merged = match environment_overlay {
3936                    None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
3937                    Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3938                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3939                    }
3940                    Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3941                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3942                    }
3943                }
3944                .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
3945                Some(merged)
3946            }
3947            (SessionMode::Pty, Some(accounts), Some(m4a_provider), None) => {
3948                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3949                match accounts.provision(m4a_provider, &session_key) {
3950                    Ok(account) => {
3951                        let identity = account.env_mutations();
3952                        let merged = match environment_overlay {
3953                            None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3954                                .map(PreparedNativeLaunchOverlay::Instance),
3955                            Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3956                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3957                            }
3958                            Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3959                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3960                            }
3961                        }
3962                        .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3963                        tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3964                        let mut merged = merged;
3965                        match accounts.write_session_file(&account) {
3966                            Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3967                            Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3968                                Ok(args) if !args.is_empty() => {
3969                                    merged = match merged {
3970                                        PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3971                                            .append_extra_args(args)
3972                                            .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3973                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3974                                        PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3975                                            .into_instance_with_extra_args(args)
3976                                            .map(PreparedNativeLaunchOverlay::Instance)
3977                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3978                                    };
3979                                    tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3980                                }
3981                                Ok(_) => {}
3982                                Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3983                            },
3984                        }
3985                        self.m4a_live
3986                            .lock()
3987                            .unwrap_or_else(|p| p.into_inner())
3988                            .insert(account.nick.clone(), (instance_id, provider.clone()));
3989                        // Letters to this account wake the session: the node listens on its mailbox.
3990                        crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3991                        Some(merged)
3992                    }
3993                    Err(error) => {
3994                        tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3995                        environment_overlay
3996                    }
3997                }
3998            }
3999            (SessionMode::Pty, Some(_), None, _) => {
4000                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4001                environment_overlay
4002            }
4003            _ => environment_overlay,
4004        };
4005        let mut instance_overlay = environment_overlay
4006            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4007            .transpose()?
4008            .flatten();
4009        Ok(PreparedSpawn {
4010            instance_overlay,
4011            environment_selection,
4012            materialization_guard,
4013            harness_mcp_overlay,
4014            #[cfg(feature = "dig2-station-probe")]
4015            browser_station_lease_guard,
4016            materialization_id,
4017        })
4018    }
4019}
4020
4021struct SessionRecords {
4022    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4023}
4024
4025struct SpawnIdempotencyCache {
4026    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4027}
4028
4029struct SpawnIdempotencyEntry {
4030    value: SpawnIdempotencyValue,
4031    expires_at: Instant,
4032}
4033
4034enum SpawnIdempotencyValue {
4035    Standard {
4036        spec: SpawnSpec,
4037        result: Result<ResolvedSpawnReceipt, NodeFailure>,
4038    },
4039    Managed {
4040        request: ManagedWorktreeSpawnRequest,
4041        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4042    },
4043    HarnessMcp,
4044}
4045
4046enum DurableManagedSpawnReplayDecision {
4047    Reserved,
4048    PendingLinked,
4049    Committed(ManagedWorktreeSpawnReceipt),
4050}
4051
4052impl SpawnIdempotencyValue {
4053    fn references_context(&self, context_id: &SpawnContextId) -> bool {
4054        match self {
4055            Self::Standard { result, .. } => result
4056                .as_ref()
4057                .ok()
4058                .and_then(|receipt| receipt.context.as_ref()),
4059            Self::Managed { result, .. } => result
4060                .as_ref()
4061                .ok()
4062                .and_then(|receipt| receipt.spawn.context.as_ref()),
4063            Self::HarnessMcp => None,
4064        }
4065        .is_some_and(|receipt| &receipt.id == context_id)
4066    }
4067}
4068
4069#[derive(Clone, Copy)]
4070struct ControllerLease {
4071    connection_id: u64,
4072    expires_at: Instant,
4073}
4074
4075struct NodeEventHistory {
4076    last_sequence: u64,
4077    replay_floor_sequence: u64,
4078    events: VecDeque<NodeEventEnvelope>,
4079    record_providers: BTreeMap<SessionRecordId, AgentId>,
4080    removed_record_providers: BTreeMap<u64, AgentId>,
4081}
4082
4083impl NodeEventHistory {
4084    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4085        Self {
4086            last_sequence: 0,
4087            replay_floor_sequence: 1,
4088            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4089            record_providers,
4090            removed_record_providers: BTreeMap::new(),
4091        }
4092    }
4093}
4094
4095impl NodeShared {
4096    /// A node-spawned grok PTY session gets its own leader socket, so its agent backend runs
4097    /// as a descendant of this session's process (with the session's own environment) and
4098    /// never attaches to an operator's shared leader. Same grok home, same login.
4099    fn session_grok_leader_socket(
4100        &self,
4101        provider: &AgentId,
4102        mode: SessionMode,
4103        instance_id: AgentInstanceId,
4104    ) -> Option<std::path::PathBuf> {
4105        if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4106            return None;
4107        }
4108        let root = self
4109            .m4a_accounts
4110            .as_ref()
4111            .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4112            .unwrap_or_else(std::env::temp_dir);
4113        let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4114        if let Some(previous) = self
4115            .grok_leaders
4116            .lock()
4117            .unwrap_or_else(|p| p.into_inner())
4118            .insert(instance_id, socket.clone())
4119        {
4120            crate::grok_leader::retire(&previous);
4121        }
4122        Some(socket)
4123    }
4124
4125    /// Stops this session's own grok leader, if it had one. Never touches any other leader.
4126    fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4127        let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4128        if let Some(socket) = socket {
4129            crate::grok_leader::retire(&socket);
4130        }
4131    }
4132
4133    fn new_with_incarnation(
4134        handle: Gate4AgentHandle,
4135        access_token: String,
4136        node_id: NodeId,
4137        incarnation_id: NodeIncarnationId,
4138        workspaces: Vec<WorkspaceConfig>,
4139        enabled_providers: Vec<AgentId>,
4140        provider_runtime_statuses: ProviderRuntimeStatuses,
4141        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4142        provider_contracts: Vec<ProviderContractSupport>,
4143        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4144        spawn_profiles: SpawnProfileRegistry,
4145        native_launch_profile_control: Option<NativeLaunchProfileControl>,
4146        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4147        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4148        state_path: Option<PathBuf>,
4149        records: Vec<ManagedSessionRecord>,
4150        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4151        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4152        materializations: Vec<MaterializationOwnershipRecord>,
4153        persistence_warning: Option<String>,
4154        input_settle_timeout_ms: u64,
4155    ) -> Self {
4156        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4157        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4158        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4159        let (harness_mcp_proxy_event_tx, _) =
4160            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4161        let record_providers = records
4162            .iter()
4163            .map(|record| (record.record_id.clone(), record.provider.clone()))
4164            .collect();
4165        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4166            .iter()
4167            .map(|workspace| {
4168                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4169            })
4170            .collect();
4171        for lease in &managed_worktrees {
4172            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4173        }
4174        let managed_worktree_profiles = workspaces.iter()
4175            .map(|workspace| (
4176                workspace.workspace_id.clone(),
4177                workspace.managed_worktree_profiles.clone(),
4178            ))
4179            .collect();
4180        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4181            managed_worktrees,
4182            managed_worktree_tombstones,
4183        ).expect("durable managed worktree registry was validated while loading");
4184        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4185        managed_worktrees.reattach_record_holders(
4186            &managed_worktree_record_holders(&records),
4187            unix_time_ms(),
4188        );
4189        Self {
4190            handle,
4191            access_token,
4192            node_id,
4193            incarnation_id,
4194            started_at_unix_ms: std::time::SystemTime::now()
4195                .duration_since(std::time::UNIX_EPOCH)
4196                .unwrap_or_default()
4197                .as_millis()
4198                .min(u64::MAX as u128) as u64,
4199            workspaces: RwLock::new(
4200                workspaces
4201                    .into_iter()
4202                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4203                    .collect(),
4204            ),
4205            worktree_service_modes,
4206            managed_worktree_profiles,
4207            managed_worktrees: Mutex::new(managed_worktrees),
4208            enabled_providers,
4209            provider_runtime_statuses,
4210            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4211            provider_runtime_monitor,
4212            provider_contracts,
4213            provider_adapter_contracts,
4214            spawn_profiles,
4215            environment_profiles: RwLock::new(BTreeMap::new()),
4216            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4217            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4218            #[cfg(feature = "dig2-station-probe")]
4219            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4220            bundle_catalog: RwLock::new(BundleCatalog::default()),
4221            delivery_store: Mutex::new(None),
4222            context_pack_store: Mutex::new(None),
4223            context_catalog: RwLock::new(ContextPackCatalog::default()),
4224            native_launch_profile_control,
4225            harness_mcp_registry: None,
4226            native_session_catalog,
4227            session_environment_materializer,
4228            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4229            m4a_live: Mutex::new(BTreeMap::new()),
4230            grok_leaders: Mutex::new(BTreeMap::new()),
4231            #[cfg(unix)]
4232            signer_proxy: std::sync::OnceLock::new(),
4233            materializations: Mutex::new(
4234                materializations
4235                    .into_iter()
4236                    .map(|record| (record.id().clone(), record))
4237                    .collect(),
4238            ),
4239            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4240                entries: BTreeMap::new(),
4241            }),
4242            managed_spawn_replays: Mutex::new(BTreeMap::new()),
4243            controller: Mutex::new(None),
4244            provisional_spawns: Mutex::new(BTreeMap::new()),
4245            pending_acp_options: Mutex::new(BTreeMap::new()),
4246            history: Mutex::new(NodeEventHistory::new(record_providers)),
4247            event_tx,
4248            terminal_event_tx,
4249            agent_stream_event_tx,
4250            harness_mcp_proxy_event_tx,
4251            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4252            pty_input_wake: tokio::sync::Notify::new(),
4253            pty_input_marks: Mutex::new(BTreeMap::new()),
4254            next_connection_id: AtomicU64::new(1),
4255            next_instance_id: AtomicU64::new(1),
4256            next_command_id: AtomicU64::new(1),
4257            shutdown: AtomicBool::new(false),
4258            shutdown_notify: Notify::new(),
4259            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4260            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4261            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4262            mutation_gate: AsyncMutex::new(()),
4263            session_bindings: Mutex::new(BTreeMap::new()),
4264            session_records: Mutex::new(SessionRecords {
4265                records: records
4266                    .into_iter()
4267                    .map(|record| (record.record_id.clone(), record))
4268                    .collect(),
4269            }),
4270            state_path,
4271            persistence_error: RwLock::new(
4272                persistence_warning
4273                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
4274            ),
4275            state_transaction: Mutex::new(()),
4276            input_settle_timeout_ms,
4277            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4278            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4279            shell_efficiency_profile: OnceLock::new(),
4280            native_session_gauge: AtomicUsize::new(0),
4281            connection_loop_iterations: AtomicU64::new(0),
4282            connection_events_sent: AtomicU64::new(0),
4283            rejected_commands_total: AtomicU64::new(0),
4284            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4285            drive_loop_iterations_total: AtomicU64::new(0),
4286            drive_loop_iterations_idle: AtomicU64::new(0),
4287            session_record_retention: SessionRecordRetentionConfig::default(),
4288            retired_records_total: AtomicU64::new(0),
4289            #[cfg(feature = "fixture")]
4290            fixture_semantic_hook_policy: false,
4291            #[cfg(feature = "fixture")]
4292            fixture_spawn_managed_worktree_v2_failure_probe:
4293                SpawnManagedWorktreeV2FailureProbe::default(),
4294        }
4295    }
4296
4297    #[cfg(test)]
4298    fn new(
4299        handle: Gate4AgentHandle,
4300        access_token: String,
4301        node_id: NodeId,
4302        workspaces: Vec<WorkspaceConfig>,
4303        enabled_providers: Vec<AgentId>,
4304    ) -> Self {
4305        Self::new_with_incarnation(
4306            handle,
4307            access_token,
4308            node_id,
4309            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4310            workspaces,
4311            enabled_providers,
4312            ProviderRuntimeStatuses::default(),
4313            None,
4314            Vec::new(),
4315            Vec::new(),
4316            SpawnProfileRegistry::default(),
4317            None,
4318            None,
4319            None,
4320            None,
4321            Vec::new(),
4322            Vec::new(),
4323            Vec::new(),
4324            Vec::new(),
4325            None,
4326            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4327        )
4328    }
4329
4330    fn persistence_error(&self) -> Option<String> {
4331        self.persistence_error
4332            .read()
4333            .unwrap_or_else(|poisoned| poisoned.into_inner())
4334            .clone()
4335    }
4336
4337    async fn admit_provider_runtime(
4338        &self,
4339        provider: &AgentId,
4340        requirement: ProviderRuntimeRequirement,
4341    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4342        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4343            let pipe_transport = monitor.supports_pipe_transport(provider);
4344            let acp_transport = monitor.supports_acp_transport(provider);
4345            let provider = provider.clone();
4346            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4347            let (status, admission) = timeout(
4348                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4349                refresh,
4350            )
4351            .await
4352            .map_err(|_| failure(
4353                NodeFailureCode::BackendBusy,
4354                "provider runtime probe exceeded its bounded deadline",
4355            ))?
4356            .map_err(|_| failure(
4357                NodeFailureCode::BackendOperationFailed,
4358                "provider runtime probe task failed",
4359            ))?;
4360            if let Some(status) = status {
4361                self
4362                    .provider_runtime_status_updates
4363                    .write()
4364                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4365                    .insert(status.provider().clone(), status);
4366            }
4367            admission.and_then(|policy| {
4368                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4369            })
4370        } else {
4371            // No live monitor means no catalog to consult -- there is
4372            // nothing here to confirm this provider declares a Pipe or ACP
4373            // transport, so Inline/Acp are rejected the same conservative
4374            // way an undeclared provider always was, rather than admitted
4375            // on trust.
4376            crate::provider_runtime::admit_status(
4377                &self.provider_runtime_statuses,
4378                provider,
4379                false,
4380                false,
4381                requirement,
4382            )
4383        };
4384        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4385        admission.map_err(|error| match error {
4386            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4387                NodeFailureCode::BackendOperationFailed,
4388                "provider launcher is unavailable",
4389            ),
4390            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4391                NodeFailureCode::UnsupportedCapability,
4392                "provider semantic capability is not verified",
4393            ),
4394            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4395                NodeFailureCode::BackendBusy,
4396                "provider runtime probe is already in progress",
4397            ),
4398        })
4399    }
4400
4401    /// Whether the catalog declares a Pipe transport for `provider` --
4402    /// the fact `require_policy` needs to admit or reject
4403    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4404    /// catalog to consult, so this stays conservative and rejects, matching
4405    /// `admit_provider_runtime`'s own no-monitor branch.
4406    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4407        match self.provider_runtime_monitor.as_deref() {
4408            Some(monitor) => monitor.supports_pipe_transport(provider),
4409            None => false,
4410        }
4411    }
4412
4413    /// Whether the catalog declares an ACP transport for `provider` -- the
4414    /// fact `require_policy` needs to admit or reject
4415    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4416    /// `provider_supports_pipe_transport` exactly, including the
4417    /// conservative no-monitor default.
4418    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4419        match self.provider_runtime_monitor.as_deref() {
4420            Some(monitor) => monitor.supports_acp_transport(provider),
4421            None => false,
4422        }
4423    }
4424
4425    /// Grants hook ingestion to a provider whose catalog entry declares a
4426    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4427    ///
4428    /// `admit_status`'s `RawPassthrough` arm hands back
4429    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4430    /// correct for everything it was written to answer, and wrong for hooks
4431    /// specifically. A hook event is not inferred from terminal behaviour,
4432    /// so it has nothing to do with whether a launcher probe verified this
4433    /// CLI version's PTY contract; the authority is the catalog declaring
4434    /// the adapter plus the ingress authenticating the route. Without this
4435    /// the node installs a provider's hooks, the CLI faithfully calls them,
4436    /// and every event is refused -- which is exactly the state grok was in.
4437    ///
4438    /// Applied to both admission paths so the monitor's own derivation and
4439    /// this coarse fallback cannot disagree about the same provider.
4440    fn admit_declared_hook_semantics(
4441        &self,
4442        provider: &AgentId,
4443        policy: ProviderRuntimePolicy,
4444    ) -> ProviderRuntimePolicy {
4445        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4446            return policy;
4447        }
4448        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4449            contract.provider == *provider && contract.family == AdapterFamily::Hook
4450        });
4451        if !declares_hook {
4452            return policy;
4453        }
4454        ProviderRuntimePolicy::new(
4455            policy.raw_pty_lifecycle,
4456            policy.semantic_readiness,
4457            policy.structured_prompt,
4458            policy.provider_session_identity,
4459            policy.semantic_resume,
4460            true,
4461        )
4462        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4463    }
4464
4465    /// Station network allowlist → provider launch overlay argv.
4466    ///
4467    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4468    ///   `provider_native` under Moderate (PTY or ACP).
4469    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4470    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4471    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4472    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4473    ///   no overlay.
4474    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4475    ///   empty overlay when no `provider_native`; invented native keys already
4476    ///   refuse at load / resolve.
4477    ///
4478    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4479    fn resolve_network_allowlist_overlay_args(
4480        &self,
4481        provider: &AgentId,
4482        mode: SessionMode,
4483        approval_level: ApprovalLevel,
4484        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4485        provider_home: Option<&Path>,
4486    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4487        let Some(allowlist_id) = network_allowlist else {
4488            return Ok(Vec::new());
4489        };
4490        let catalog = self
4491            .network_allowlist_catalog
4492            .read()
4493            .unwrap_or_else(|poisoned| poisoned.into_inner());
4494        let Some(entry) = catalog.get(allowlist_id) else {
4495            return Err(failure(
4496                NodeFailureCode::UnknownNetworkAllowlist,
4497                "spawn network allowlist is unavailable on this node",
4498            ));
4499        };
4500
4501        // Claude: settings-shaped overlay from permits (not provider_native).
4502        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4503            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4504                |message| {
4505                    failure(
4506                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4507                        &message,
4508                    )
4509                },
4510            )?;
4511            if !matches!(mode, SessionMode::Pty) {
4512                return Err(failure(
4513                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4514                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4515                ));
4516            }
4517            let Some(home) = provider_home else {
4518                return Err(failure(
4519                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4520                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4521                ));
4522            };
4523            let domains =
4524                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4525            if domains.is_empty() {
4526                return Err(failure(
4527                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4528                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4529                ));
4530            }
4531            let settings_path = home.join(
4532                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4533            );
4534            let json =
4535                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4536            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4537                |_| {
4538                    failure(
4539                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4540                        "Claude station network settings sidecar could not be written under ProviderHome",
4541                    )
4542                },
4543            )?;
4544            let overlay =
4545                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4546            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4547        }
4548
4549        let Some(native) = entry.provider_native.as_ref() else {
4550            return Ok(Vec::new());
4551        };
4552        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4553            provider.as_str(),
4554            approval_level,
4555            native,
4556        )
4557        .map_err(|message| {
4558            failure(
4559                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4560                &message,
4561            )
4562        })?;
4563        if overlay.is_empty() {
4564            return Ok(Vec::new());
4565        }
4566        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4567            return Err(failure(
4568                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4569                "Codex network_access -c overlay requires PTY or ACP transport",
4570            ));
4571        }
4572        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4573    }
4574
4575    /// Backward-compatible alias used by Codex-focused unit tests.
4576    fn resolve_codex_network_access_overlay_args(
4577        &self,
4578        provider: &AgentId,
4579        mode: SessionMode,
4580        approval_level: ApprovalLevel,
4581        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4582    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4583        self.resolve_network_allowlist_overlay_args(
4584            provider,
4585            mode,
4586            approval_level,
4587            network_allowlist,
4588            None,
4589        )
4590    }
4591
4592    fn resolve_environment_profile(
4593        &self,
4594        resolved: &ResolvedSpawnSpec,
4595    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4596        // Station network allowlist catalog enforce (plan
4597        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4598        // catalog — unknown ids refuse rather than silent ambient. Runs even
4599        // when no environment_profile_id is present so a bare allowlist still
4600        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4601        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4602            let catalog = self
4603                .network_allowlist_catalog
4604                .read()
4605                .unwrap_or_else(|poisoned| poisoned.into_inner());
4606            let Some(entry) = catalog.get(allowlist_id) else {
4607                return Err(failure(
4608                    NodeFailureCode::UnknownNetworkAllowlist,
4609                    "spawn network allowlist is unavailable on this node",
4610                ));
4611            };
4612            // Track B: refuse provider-native mappings this provider cannot
4613            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4614            if let Some(native) = entry.provider_native.as_ref() {
4615                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4616                    resolved.provider.as_str(),
4617                    resolved.approval_level,
4618                    native,
4619                ) {
4620                    return Err(failure(
4621                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4622                        &message,
4623                    ));
4624                }
4625            }
4626        }
4627        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4628        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4629        // cheap named-pipe path reachability must succeed (Windows
4630        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4631        // NodeFailureCode. Runs even without environment_profile_id (same
4632        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4633        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4634        // acquired later in spawn_session_with_deadline (after instance id).
4635        #[cfg(feature = "dig2-station-probe")]
4636        if resolved.browser_profile_id.is_some() {
4637            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4638            match probe_station_reachable() {
4639                Ok(()) => {}
4640                Err(StationProbeError::UnsupportedPlatform)
4641                | Err(StationProbeError::InvalidPipeSuffix) => {
4642                    return Err(failure(
4643                        NodeFailureCode::BrowserStationProbeUnavailable,
4644                        "dig2browser station probe unavailable on this platform",
4645                    ));
4646                }
4647                Err(StationProbeError::Unreachable) => {
4648                    return Err(failure(
4649                        NodeFailureCode::BrowserStationUnreachable,
4650                        "dig2browser station named-pipe is unreachable",
4651                    ));
4652                }
4653            }
4654        }
4655        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4656            return Ok(None);
4657        };
4658        let profiles = self
4659            .environment_profiles
4660            .read()
4661            .unwrap_or_else(|poisoned| poisoned.into_inner());
4662        let binding = profiles.get(profile_id).ok_or_else(|| {
4663            failure(
4664                NodeFailureCode::UnknownEnvironmentProfile,
4665                "spawn environment profile is unavailable on this node",
4666            )
4667        })?;
4668        if binding.provider != resolved.provider
4669            || binding.native_profile_id(resolved.mode).is_none()
4670        {
4671            return Err(failure(
4672                NodeFailureCode::EnvironmentProfileBindingMismatch,
4673                "spawn environment profile does not match the provider and mode",
4674            ));
4675        }
4676        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4677        // after catalog + optional dig2 probe above. Empty/whitespace ids
4678        // already refuse at Spawn*Id construction.
4679        Ok(Some(ResolvedEnvironmentProfileReceipt {
4680            profile_id: binding.id.clone(),
4681            profile_revision: binding.revision.clone(),
4682            network_allowlist: resolved.network_allowlist.clone(),
4683            browser_profile_id: resolved.browser_profile_id.clone(),
4684        }))
4685    }
4686
4687    fn resolve_bundle(
4688        &self,
4689        resolved: &ResolvedSpawnSpec,
4690        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4691    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4692        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4693            return Ok(None);
4694        };
4695        let catalog = self.bundle_catalog
4696            .read()
4697            .unwrap_or_else(|poisoned| poisoned.into_inner());
4698        let bundle = catalog.get(bundle_id).ok_or_else(|| {
4699                failure(
4700                    NodeFailureCode::UnknownBundle,
4701                    "spawn bundle is unavailable on this node",
4702                )
4703            })?;
4704        self.bundle_layout(
4705            &resolved.provider,
4706            resolved.mode,
4707            environment_profile,
4708            bundle,
4709        )?;
4710        Ok(Some(bundle.receipt()))
4711    }
4712
4713    fn begin_delivery_stage(
4714        &self,
4715        manifest: DeliveryBundleManifestV2,
4716    ) -> Result<NodeResponse, NodeFailure> {
4717        let mut store = self
4718            .delivery_store
4719            .lock()
4720            .unwrap_or_else(|poisoned| poisoned.into_inner());
4721        let store = store.as_mut().ok_or_else(|| {
4722            failure(
4723                NodeFailureCode::DeliveryStageStorageFailed,
4724                "node delivery store is unavailable",
4725            )
4726        })?;
4727        let begun = store.begin(manifest).map_err(delivery_failure)?;
4728        Ok(NodeResponse::DeliveryStageBegun {
4729            stage_id: begun.stage_id,
4730            manifest_digest: begun.manifest_digest,
4731            missing_blobs: begun.missing_blobs,
4732        })
4733    }
4734
4735    fn put_delivery_blob_chunk(
4736        &self,
4737        stage_id: DeliveryStageId,
4738        blob_digest: DeliveryBlobDigestV1,
4739        offset: u64,
4740        chunk_hex: DeliveryBlobChunkHexV1,
4741    ) -> Result<NodeResponse, NodeFailure> {
4742        let mut store = self
4743            .delivery_store
4744            .lock()
4745            .unwrap_or_else(|poisoned| poisoned.into_inner());
4746        let store = store.as_mut().ok_or_else(|| {
4747            failure(
4748                NodeFailureCode::DeliveryStageStorageFailed,
4749                "node delivery store is unavailable",
4750            )
4751        })?;
4752        let accepted = store
4753            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4754            .map_err(delivery_failure)?;
4755        Ok(NodeResponse::DeliveryBlobChunkAccepted {
4756            stage_id: accepted.stage_id,
4757            blob_digest: accepted.blob_digest,
4758            next_offset: accepted.next_offset,
4759        })
4760    }
4761
4762    fn commit_delivery_stage(
4763        &self,
4764        stage_id: DeliveryStageId,
4765    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4766        let mut store = self
4767            .delivery_store
4768            .lock()
4769            .unwrap_or_else(|poisoned| poisoned.into_inner());
4770        let store = store.as_mut().ok_or_else(|| {
4771            failure(
4772                NodeFailureCode::DeliveryStageStorageFailed,
4773                "node delivery store is unavailable",
4774            )
4775        })?;
4776        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4777        let mut catalog = self
4778            .bundle_catalog
4779            .write()
4780            .unwrap_or_else(|poisoned| poisoned.into_inner());
4781        let mut next_catalog = catalog.clone();
4782        next_catalog
4783            .insert_idempotent(prepared.bundle().clone())
4784            .map_err(|_| {
4785                failure(
4786                    NodeFailureCode::DeliveryStageConflict,
4787                    "delivery bundle identity conflicts with installed content",
4788                )
4789            })?;
4790        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4791        *catalog = next_catalog;
4792        Ok(receipt)
4793    }
4794
4795    fn abort_delivery_stage(
4796        &self,
4797        stage_id: &DeliveryStageId,
4798    ) -> Result<(), NodeFailure> {
4799        let mut store = self
4800            .delivery_store
4801            .lock()
4802            .unwrap_or_else(|poisoned| poisoned.into_inner());
4803        let store = store.as_mut().ok_or_else(|| {
4804            failure(
4805                NodeFailureCode::DeliveryStageStorageFailed,
4806                "node delivery store is unavailable",
4807            )
4808        })?;
4809        store.abort(stage_id).map_err(delivery_failure)
4810    }
4811
4812    fn resolve_context(
4813        &self,
4814        resolved: &ResolvedSpawnSpec,
4815    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4816        let Some(context_id) = resolved.context_id.as_ref() else {
4817            return Ok(None);
4818        };
4819        self.context_catalog
4820            .read()
4821            .unwrap_or_else(|poisoned| poisoned.into_inner())
4822            .get(context_id)
4823            .map(|context| context.receipt().clone())
4824            .ok_or_else(|| {
4825                failure(
4826                    NodeFailureCode::UnknownContextPack,
4827                    "spawn context pack is unavailable on this node",
4828                )
4829            })
4830            .map(Some)
4831    }
4832
4833    fn bundle_layout(
4834        &self,
4835        provider: &AgentId,
4836        mode: SessionMode,
4837        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4838        bundle: &NodeBundle,
4839    ) -> Result<BundleProviderLayout, NodeFailure> {
4840        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4841            failure(
4842                NodeFailureCode::BundleBindingMismatch,
4843                "spawn bundle does not match the provider and mode",
4844            )
4845        })?;
4846        if layout != BundleProviderLayout::Codex {
4847            return Ok(layout);
4848        }
4849        let profile = environment_profile.and_then(|receipt| {
4850            let profiles = self
4851                .environment_profiles
4852                .read()
4853                .unwrap_or_else(|poisoned| poisoned.into_inner());
4854            let binding = profiles.get(&receipt.profile_id)?;
4855            if binding.revision != receipt.profile_revision
4856                || &binding.provider != provider
4857                || binding.native_profile_id(mode).is_none()
4858            {
4859                return None;
4860            }
4861            self.environment_materialization_profiles
4862                .read()
4863                .unwrap_or_else(|poisoned| poisoned.into_inner())
4864                .get(&receipt.profile_id)
4865                .cloned()
4866        });
4867        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4868            return Err(failure(
4869                NodeFailureCode::BundleBindingMismatch,
4870                "Codex bundle requires an exact isolated CODEX_HOME profile",
4871            ));
4872        }
4873        Ok(layout)
4874    }
4875
4876    fn select_environment_profile(
4877        &self,
4878        instance_id: AgentInstanceId,
4879        provider: &AgentId,
4880        mode: SessionMode,
4881        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4882    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4883        let Some(environment_profile) = environment_profile else {
4884            return Ok(None);
4885        };
4886        let native_profile_id = {
4887            let profiles = self
4888                .environment_profiles
4889                .read()
4890                .unwrap_or_else(|poisoned| poisoned.into_inner());
4891            let binding = profiles
4892                .get(&environment_profile.profile_id)
4893                .ok_or_else(|| {
4894                    failure(
4895                        NodeFailureCode::UnknownEnvironmentProfile,
4896                        "session environment profile is unavailable on this node",
4897                    )
4898                })?;
4899            if &binding.revision != &environment_profile.profile_revision
4900                || &binding.provider != provider
4901            {
4902                return Err(failure(
4903                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4904                    "session environment profile revision or provider changed",
4905                ));
4906            }
4907            binding.native_profile_id(mode).cloned().ok_or_else(|| {
4908                failure(
4909                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4910                    "session environment profile does not support this mode",
4911                )
4912            })?
4913        };
4914        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4915            failure(
4916                NodeFailureCode::BackendOperationFailed,
4917                "native environment profile controller is unavailable",
4918            )
4919        })?;
4920        control
4921            .select_native_launch_profile(instance_id, native_profile_id)
4922            .map_err(|_| {
4923                failure(
4924                    NodeFailureCode::BackendOperationFailed,
4925                    "native environment profile selection failed",
4926                )
4927            })?;
4928        Ok(Some(NativeEnvironmentSelectionGuard {
4929            control: Some(control),
4930            instance_id,
4931        }))
4932    }
4933
4934    fn install_prepared_launch_overlay(
4935        &self,
4936        instance_id: AgentInstanceId,
4937        overlay: PreparedNativeLaunchOverlay,
4938    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4939        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4940            failure(
4941                NodeFailureCode::BackendOperationFailed,
4942                "native launch profile controller is unavailable",
4943            )
4944        })?;
4945        match overlay {
4946            PreparedNativeLaunchOverlay::Environment(overlay) => {
4947                control
4948                    .install_native_launch_environment_overlay(instance_id, overlay)
4949                    .map_err(|_| failure(
4950                        NodeFailureCode::BackendOperationFailed,
4951                        "session environment overlay installation failed",
4952                    ))?;
4953                Ok(None)
4954            }
4955            PreparedNativeLaunchOverlay::Instance(overlay) => {
4956                control
4957                    .install_native_instance_launch_overlay(instance_id, overlay)
4958                    .map_err(|_| failure(
4959                        NodeFailureCode::BundleBindingMismatch,
4960                        "session bundle launch overlay installation failed",
4961                    ))?;
4962                Ok(Some(NativeInstanceOverlayGuard {
4963                    control: Some(control),
4964                    instance_id,
4965                }))
4966            }
4967        }
4968    }
4969
4970    fn environment_materialization_profile(
4971        &self,
4972        environment_profile: &ResolvedEnvironmentProfileReceipt,
4973    ) -> Option<NodeSessionMaterializationProfile> {
4974        self.environment_materialization_profiles
4975            .read()
4976            .unwrap_or_else(|poisoned| poisoned.into_inner())
4977            .get(&environment_profile.profile_id)
4978            .cloned()
4979    }
4980
4981    fn materialization_profile(
4982        &self,
4983        provider: &AgentId,
4984        mode: SessionMode,
4985        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4986        bundle: Option<&ResolvedBundleReceipt>,
4987        context: Option<&ResolvedContextPackReceipt>,
4988    ) -> Result<Option<(
4989        NodeSessionMaterializationProfile,
4990        bool,
4991        Option<BundleProviderLayout>,
4992    )>, NodeFailure> {
4993        let environment = environment_profile
4994            .and_then(|receipt| self.environment_materialization_profile(receipt));
4995        let (bundle, bundle_layout) = match bundle {
4996            Some(receipt) => {
4997                let catalog = self.bundle_catalog.read()
4998                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4999                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5000                    NodeFailureCode::UnknownBundle,
5001                    "session bundle is unavailable on this node",
5002                ))?;
5003                if installed.receipt() != *receipt {
5004                    return Err(failure(
5005                        NodeFailureCode::BundleBindingMismatch,
5006                        "session bundle revision or digest changed",
5007                    ));
5008                }
5009                let layout = self.bundle_layout(
5010                    provider,
5011                    mode,
5012                    environment_profile,
5013                    installed,
5014                )?;
5015                (Some(installed.clone()), Some(layout))
5016            }
5017            None => (None, None),
5018        };
5019        let has_environment_overlay = environment.is_some() || context.is_some();
5020        let profile = match (environment, bundle.as_ref(), bundle_layout) {
5021            (None, None, None) => None,
5022            (Some(profile), None, None) => Some(Ok(profile)),
5023            (None, Some(bundle), Some(layout)) => {
5024                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5025            }
5026            (Some(profile), Some(bundle), Some(layout)) => {
5027                Some(profile.with_bundle(bundle, layout))
5028            }
5029            _ => {
5030                return Err(failure(
5031                    NodeFailureCode::BundleBindingMismatch,
5032                    "session bundle provider layout is unavailable",
5033                ));
5034            }
5035        };
5036        let profile = profile
5037            .transpose()
5038            .map_err(|_| failure(
5039                NodeFailureCode::BundleMaterializationFailed,
5040                "session bundle materialization profile is invalid",
5041            ))?;
5042        let profile = match (profile, context) {
5043            (Some(profile), Some(receipt)) => {
5044                let catalog = self.context_catalog
5045                    .read()
5046                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5047                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5048                    NodeFailureCode::UnknownContextPack,
5049                    "session context pack is unavailable on this node",
5050                ))?;
5051                if pack.receipt() != receipt {
5052                    return Err(failure(
5053                        NodeFailureCode::ContextPackMaterializationFailed,
5054                        "session context pack identity changed",
5055                    ));
5056                }
5057                profile.with_context(pack)
5058            }
5059            (None, Some(receipt)) => {
5060                let catalog = self.context_catalog
5061                    .read()
5062                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5063                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5064                    NodeFailureCode::UnknownContextPack,
5065                    "session context pack is unavailable on this node",
5066                ))?;
5067                if pack.receipt() != receipt {
5068                    return Err(failure(
5069                        NodeFailureCode::ContextPackMaterializationFailed,
5070                        "session context pack identity changed",
5071                    ));
5072                }
5073                NodeSessionMaterializationProfile::from_context(pack)
5074            }
5075            (Some(profile), None) => Ok(profile),
5076            (None, None) => return Ok(None),
5077        }
5078        .map_err(|_| failure(
5079            NodeFailureCode::ContextPackMaterializationFailed,
5080            "session context pack materialization profile is invalid",
5081        ))?;
5082        Ok(Some((profile, has_environment_overlay, bundle_layout)))
5083    }
5084
5085    fn prepare_session_materialization<'a>(
5086        &'a self,
5087        address: &SessionAddress,
5088        provider: &AgentId,
5089        mode: SessionMode,
5090        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5091        bundle: Option<&ResolvedBundleReceipt>,
5092        context: Option<&ResolvedContextPackReceipt>,
5093        managed_lease_id: Option<ManagedWorktreeLeaseId>,
5094    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5095        let Some((profile, has_environment_overlay, bundle_layout)) =
5096            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5097        else {
5098            return Ok(None);
5099        };
5100        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5101            failure(
5102                NodeFailureCode::BackendOperationFailed,
5103                "session environment materializer is unavailable",
5104            )
5105        })?;
5106        let materialization_failure_code = if context.is_some() {
5107            NodeFailureCode::ContextPackMaterializationFailed
5108        } else if bundle.is_some() {
5109            NodeFailureCode::BundleMaterializationFailed
5110        } else {
5111            NodeFailureCode::BackendOperationFailed
5112        };
5113        let id = MaterializationId::new(format!(
5114            "mat-{}-{}-{}",
5115            self.incarnation_id,
5116            address.session.instance_id.0,
5117            address.session.generation.0,
5118        ))
5119        .map_err(|_| {
5120            failure(
5121                NodeFailureCode::BackendOperationFailed,
5122                "session environment materialization identity failed",
5123            )
5124        })?;
5125        let owner = MaterializationOwner::Session {
5126            incarnation_id: self.incarnation_id,
5127            instance_id: address.session.instance_id,
5128            generation: address.session.generation,
5129        };
5130        let mut ownership = materializer
5131            .begin(
5132                id.clone(),
5133                environment_profile.cloned(),
5134                bundle.cloned(),
5135                context.cloned(),
5136                owner,
5137                managed_lease_id,
5138                &profile,
5139                unix_time_ms(),
5140            )
5141            .map_err(|_| {
5142                failure(
5143                    materialization_failure_code,
5144                    "session materialization preparation failed",
5145                )
5146            })?;
5147        {
5148            let _transaction = self
5149                .state_transaction
5150                .lock()
5151                .unwrap_or_else(|poisoned| poisoned.into_inner());
5152            let duplicate = self
5153                .materializations
5154                .lock()
5155                .unwrap_or_else(|poisoned| poisoned.into_inner())
5156                .insert(id.clone(), ownership.clone());
5157            if duplicate.is_some() {
5158                return Err(failure(
5159                    NodeFailureCode::BackendOperationFailed,
5160                    "session environment materialization identity conflict",
5161                ));
5162            }
5163            if let Err(error) = self.persist_state_locked() {
5164                self.materializations
5165                    .lock()
5166                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5167                    .remove(&id);
5168                return Err(persistence_failure(error));
5169            }
5170        }
5171        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5172            Ok(environment) => environment,
5173            Err(_) => {
5174                let transaction = self
5175                    .state_transaction
5176                    .lock()
5177                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5178                self.materializations
5179                    .lock()
5180                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5181                    .insert(id.clone(), ownership.clone());
5182                if self.persist_state_locked().is_err() {
5183                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5184                }
5185                drop(transaction);
5186                if ownership.state() == MaterializationState::CleanupRequired {
5187                    let _ = self.cleanup_materialization(&id);
5188                }
5189                return Err(failure(
5190                    materialization_failure_code,
5191                    "session materialization failed",
5192                ));
5193            }
5194        };
5195        {
5196            let _transaction = self
5197                .state_transaction
5198                .lock()
5199                .unwrap_or_else(|poisoned| poisoned.into_inner());
5200            self.materializations
5201                .lock()
5202                .unwrap_or_else(|poisoned| poisoned.into_inner())
5203                .insert(id.clone(), ownership.clone());
5204            if let Err(error) = self.persist_state_locked() {
5205                drop(_transaction);
5206                let _ = self.cleanup_materialization(&id);
5207                return Err(persistence_failure(error));
5208            }
5209        }
5210        let guard = SessionMaterializationGuard {
5211            shared: self,
5212            id: Some(id),
5213            provider_home: Some(ownership.provider_home().to_path_buf()),
5214        };
5215        let bundle_arguments = match bundle {
5216            Some(receipt) => {
5217                let catalog = self.bundle_catalog.read()
5218                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5219                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5220                    NodeFailureCode::UnknownBundle,
5221                    "session bundle is unavailable on this node",
5222                ))?;
5223                if installed.receipt() != *receipt {
5224                    return Err(failure(
5225                        NodeFailureCode::BundleBindingMismatch,
5226                        "session bundle revision or digest changed",
5227                    ));
5228                }
5229                let layout = bundle_layout.ok_or_else(|| failure(
5230                    NodeFailureCode::BundleBindingMismatch,
5231                    "materialized bundle provider layout is unavailable",
5232                ))?;
5233                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5234                    failure(
5235                        NodeFailureCode::BundleMaterializationFailed,
5236                        "session bundle byte revalidation failed",
5237                    )
5238                })?;
5239                Some(bundle_launch_arguments(
5240                    layout,
5241                    ownership.bundle_root(),
5242                    ownership.provider_home(),
5243                ).map_err(|_| failure(
5244                    NodeFailureCode::BundleBindingMismatch,
5245                    "session bundle launch binding is invalid",
5246                ))?)
5247            }
5248            None => None,
5249        };
5250        if let Some(receipt) = context {
5251            materializer
5252                .revalidate_context(&ownership, receipt)
5253                .map_err(|_| failure(
5254                    NodeFailureCode::ContextPackMaterializationFailed,
5255                    "session context pack byte revalidation failed",
5256                ))?;
5257        }
5258        let transport = match mode {
5259            SessionMode::Pty => TransportKind::Pty,
5260            SessionMode::Inline => TransportKind::Pipe,
5261            SessionMode::Acp => TransportKind::Acp,
5262        };
5263        let overlay = if let Some(extra_args) = bundle_arguments {
5264            Some(PreparedNativeLaunchOverlay::Instance(
5265                NativeInstanceLaunchOverlay::new(
5266                    provider.clone(),
5267                    transport,
5268                    if has_environment_overlay {
5269                        environment
5270                    } else {
5271                        Vec::new()
5272                    },
5273                    extra_args,
5274                )
5275                .map_err(|_| {
5276                    failure(
5277                        NodeFailureCode::BundleBindingMismatch,
5278                        "session bundle launch overlay is invalid",
5279                    )
5280                })?,
5281            ))
5282        } else if has_environment_overlay {
5283            let context_only = environment_profile.is_none()
5284                && bundle.is_none()
5285                && context.is_some();
5286            let overlay = if context_only {
5287                NativeLaunchEnvironmentOverlay::new_context_root(
5288                    provider.clone(),
5289                    transport,
5290                    environment,
5291                )
5292                .map_err(|_| {
5293                    failure(
5294                        NodeFailureCode::ContextPackMaterializationFailed,
5295                        "context-only launch overlay is invalid",
5296                    )
5297                })?
5298            } else {
5299                NativeLaunchEnvironmentOverlay::new(
5300                    provider.clone(),
5301                    transport,
5302                    environment,
5303                )
5304                .map_err(|_| {
5305                    failure(
5306                        NodeFailureCode::BackendOperationFailed,
5307                        "session environment overlay is invalid",
5308                    )
5309                })?
5310            };
5311            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5312        } else {
5313            None
5314        };
5315        Ok(Some((overlay, guard)))
5316    }
5317
5318    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5319        let Some(mut ownership) = self
5320            .materializations
5321            .lock()
5322            .unwrap_or_else(|poisoned| poisoned.into_inner())
5323            .get(id)
5324            .cloned()
5325        else {
5326            return Ok(());
5327        };
5328        if ownership.state() != MaterializationState::CleanupRequired {
5329            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5330                failure(
5331                    NodeFailureCode::BackendOperationFailed,
5332                    "session environment cleanup state is invalid",
5333                )
5334            })?;
5335        }
5336        {
5337            let _transaction = self
5338                .state_transaction
5339                .lock()
5340                .unwrap_or_else(|poisoned| poisoned.into_inner());
5341            let previous = self
5342                .materializations
5343                .lock()
5344                .unwrap_or_else(|poisoned| poisoned.into_inner())
5345                .insert(id.clone(), ownership.clone());
5346            if let Err(error) = self.persist_state_locked() {
5347                let mut materializations = self
5348                    .materializations
5349                    .lock()
5350                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5351                if let Some(previous) = previous {
5352                    materializations.insert(id.clone(), previous);
5353                }
5354                return Err(persistence_failure(error));
5355            }
5356        }
5357        let cleanup = self
5358            .session_environment_materializer
5359            .as_ref()
5360            .ok_or(())
5361            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5362        if cleanup.is_err() {
5363            let _ = ownership.mark_recovery_required(unix_time_ms());
5364            self.materializations
5365                .lock()
5366                .unwrap_or_else(|poisoned| poisoned.into_inner())
5367                .insert(id.clone(), ownership);
5368            if self.persist_state().is_err() {
5369                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5370            }
5371            return Err(failure(
5372                NodeFailureCode::BackendOperationFailed,
5373                "session environment cleanup requires recovery",
5374            ));
5375        }
5376        let _transaction = self
5377            .state_transaction
5378            .lock()
5379            .unwrap_or_else(|poisoned| poisoned.into_inner());
5380        self.materializations
5381            .lock()
5382            .unwrap_or_else(|poisoned| poisoned.into_inner())
5383            .remove(id);
5384        if let Err(error) = self.persist_state_locked() {
5385            self.materializations
5386                .lock()
5387                .unwrap_or_else(|poisoned| poisoned.into_inner())
5388                .insert(id.clone(), ownership);
5389            return Err(persistence_failure(error));
5390        }
5391        Ok(())
5392    }
5393
5394    fn resolve_record_materialization(
5395        &self,
5396        record_id: &SessionRecordId,
5397        provider: &AgentId,
5398        mode: SessionMode,
5399        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5400        bundle: Option<&ResolvedBundleReceipt>,
5401        context: Option<&ResolvedContextPackReceipt>,
5402    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5403        let ownership = {
5404            let materializations = self
5405                .materializations
5406                .lock()
5407                .unwrap_or_else(|poisoned| poisoned.into_inner());
5408            let mut matches = materializations.values().filter(|record| {
5409                record.owner()
5410                    == &MaterializationOwner::Record {
5411                        record_id: record_id.clone(),
5412                    }
5413            });
5414            let ownership = matches.next().cloned();
5415            if matches.next().is_some() {
5416                return Err(failure(
5417                    NodeFailureCode::BackendOperationFailed,
5418                    "managed session has conflicting environment ownership",
5419                ));
5420            }
5421            ownership
5422        };
5423        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5424            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5425                failure(
5426                    NodeFailureCode::ContextPackMaterializationFailed,
5427                    "session context materializer is unavailable",
5428                )
5429            })?;
5430            let pack = materializer
5431                .revalidate_context(ownership, receipt)
5432                .map_err(|_| {
5433                    self.mark_materialization_recovery_required(ownership.id());
5434                    failure(
5435                        NodeFailureCode::ContextPackMaterializationFailed,
5436                        "managed session context pack revalidation failed",
5437                    )
5438                })?;
5439            self.context_catalog
5440                .write()
5441                .unwrap_or_else(|poisoned| poisoned.into_inner())
5442                .insert(pack)
5443                .map_err(|_| failure(
5444                    NodeFailureCode::ContextPackMaterializationFailed,
5445                    "managed session context pack catalog restore failed",
5446                ))?;
5447        }
5448        let materialization = self.materialization_profile(
5449            provider,
5450            mode,
5451            environment_profile,
5452            bundle,
5453            context,
5454        )?;
5455        match (ownership, materialization) {
5456            (None, None) => Ok(None),
5457            (None, Some(_)) | (Some(_), None) => Err(failure(
5458                NodeFailureCode::BackendOperationFailed,
5459                "managed session materialization is unavailable",
5460            )),
5461            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5462                if ownership.environment_profile() != environment_profile
5463                    || ownership.bundle() != bundle
5464                    || ownership.context() != context
5465                    || ownership.state() != MaterializationState::Ready
5466                {
5467                    self.mark_materialization_recovery_required(ownership.id());
5468                    return Err(failure(
5469                        NodeFailureCode::BackendOperationFailed,
5470                        "managed session environment ownership requires recovery",
5471                    ));
5472                }
5473                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5474                    failure(
5475                        NodeFailureCode::BackendOperationFailed,
5476                        "session environment materializer is unavailable",
5477                    )
5478                })?;
5479                if materializer.revalidate(&ownership).is_err() {
5480                    self.mark_materialization_recovery_required(ownership.id());
5481                    return Err(failure(
5482                        NodeFailureCode::BackendOperationFailed,
5483                        "managed session environment revalidation failed",
5484                    ));
5485                }
5486                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5487                    let catalog = self.bundle_catalog.read()
5488                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5489                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5490                        return Err(failure(
5491                            NodeFailureCode::UnknownBundle,
5492                            "managed session bundle is unavailable",
5493                        ));
5494                    };
5495                    let layout = bundle_layout.ok_or_else(|| failure(
5496                        NodeFailureCode::BundleBindingMismatch,
5497                        "record bundle provider layout is unavailable",
5498                    ))?;
5499                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5500                        drop(catalog);
5501                        self.mark_materialization_recovery_required(ownership.id());
5502                        return Err(failure(
5503                            NodeFailureCode::BundleMaterializationFailed,
5504                            "managed session bundle revalidation failed",
5505                        ));
5506                    }
5507                    Some(bundle_launch_arguments(
5508                        layout,
5509                        ownership.bundle_root(),
5510                        ownership.provider_home(),
5511                    ).map_err(|_| failure(
5512                        NodeFailureCode::BundleBindingMismatch,
5513                        "managed session bundle launch binding is invalid",
5514                    ))?)
5515                } else {
5516                    None
5517                };
5518                if !has_environment_materialization {
5519                    let overlay = bundle_arguments.map(|extra_args| {
5520                        NativeInstanceLaunchOverlay::new(
5521                            provider.clone(),
5522                            TransportKind::Pty,
5523                            Vec::new(),
5524                            extra_args,
5525                        )
5526                        .map(PreparedNativeLaunchOverlay::Instance)
5527                        .map_err(|_| failure(
5528                            NodeFailureCode::BundleBindingMismatch,
5529                            "managed session bundle launch overlay is invalid",
5530                        ))
5531                    }).transpose()?;
5532                    return Ok(Some((ownership.id().clone(), overlay)));
5533                }
5534                let environment = match materializer.resolve_environment(&ownership, &profile) {
5535                    Ok(environment) => environment,
5536                    Err(
5537                        SessionEnvironmentMaterializeError::SecretUnavailable
5538                        | SessionEnvironmentMaterializeError::SecretDenied
5539                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5540                    ) => {
5541                        return Err(failure(
5542                            NodeFailureCode::BackendOperationFailed,
5543                            "managed session environment resolution failed",
5544                        ));
5545                    }
5546                    Err(
5547                        SessionEnvironmentMaterializeError::InvalidRoot
5548                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5549                        | SessionEnvironmentMaterializeError::Filesystem(_)
5550                        | SessionEnvironmentMaterializeError::Record(_),
5551                    ) => {
5552                        self.mark_materialization_recovery_required(ownership.id());
5553                        return Err(failure(
5554                            NodeFailureCode::BackendOperationFailed,
5555                            "managed session environment resolution failed",
5556                        ));
5557                    }
5558                };
5559                let transport = match mode {
5560                    SessionMode::Pty => TransportKind::Pty,
5561                    SessionMode::Inline => TransportKind::Pipe,
5562                    SessionMode::Acp => TransportKind::Acp,
5563                };
5564                let overlay = match bundle_arguments {
5565                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5566                        NativeInstanceLaunchOverlay::new(
5567                            provider.clone(),
5568                            transport,
5569                            environment,
5570                            extra_args,
5571                        )
5572                        .map_err(|_| failure(
5573                            NodeFailureCode::BundleBindingMismatch,
5574                            "managed session bundle launch overlay is invalid",
5575                        ))?,
5576                    ),
5577                    None => PreparedNativeLaunchOverlay::Environment(
5578                        NativeLaunchEnvironmentOverlay::new(
5579                            provider.clone(),
5580                            transport,
5581                            environment,
5582                        )
5583                        .map_err(|_| {
5584                            failure(
5585                                NodeFailureCode::BackendOperationFailed,
5586                                "managed session environment overlay is invalid",
5587                            )
5588                        })?,
5589                    ),
5590                };
5591                Ok(Some((ownership.id().clone(), Some(overlay))))
5592            }
5593        }
5594    }
5595
5596    fn destroy_record_materialization(
5597        &self,
5598        record_id: &SessionRecordId,
5599    ) -> Result<bool, NodeFailure> {
5600        let ids = self
5601            .materializations
5602            .lock()
5603            .unwrap_or_else(|poisoned| poisoned.into_inner())
5604            .values()
5605            .filter_map(|record| {
5606                (record.owner()
5607                    == &MaterializationOwner::Record {
5608                        record_id: record_id.clone(),
5609                    })
5610                .then(|| record.id().clone())
5611            })
5612            .collect::<Vec<_>>();
5613        if ids.len() > 1 {
5614            return Err(failure(
5615                NodeFailureCode::BackendOperationFailed,
5616                "managed session has conflicting environment ownership",
5617            ));
5618        }
5619        let Some(id) = ids.first().cloned() else {
5620            return Ok(false);
5621        };
5622        let (terminal_record, mut cleanup_ownership) = {
5623            let _transaction = self
5624                .state_transaction
5625                .lock()
5626                .unwrap_or_else(|poisoned| poisoned.into_inner());
5627            let original_record = self.record(record_id)?;
5628            let mut terminal_record = original_record.clone();
5629            terminal_record.provider_session = None;
5630            terminal_record.active_session = None;
5631            terminal_record.state = ManagedSessionState::Unavailable;
5632            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5633            terminal_record.updated_at_unix_ms = unix_time_ms();
5634            let original_ownership = self
5635                .materializations
5636                .lock()
5637                .unwrap_or_else(|poisoned| poisoned.into_inner())
5638                .get(&id)
5639                .cloned()
5640                .ok_or_else(|| {
5641                    failure(
5642                        NodeFailureCode::BackendOperationFailed,
5643                        "managed session environment ownership disappeared during cleanup",
5644                    )
5645                })?;
5646            let mut cleanup_ownership = original_ownership.clone();
5647            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5648                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5649                    failure(
5650                        NodeFailureCode::BackendOperationFailed,
5651                        "managed session environment cleanup state is invalid",
5652                    )
5653                })?;
5654            }
5655            self.session_records
5656                .lock()
5657                .unwrap_or_else(|poisoned| poisoned.into_inner())
5658                .records
5659                .insert(record_id.clone(), terminal_record.clone());
5660            self.materializations
5661                .lock()
5662                .unwrap_or_else(|poisoned| poisoned.into_inner())
5663                .insert(id.clone(), cleanup_ownership.clone());
5664            if let Err(error) = self.persist_state_locked() {
5665                self.session_records
5666                    .lock()
5667                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5668                    .records
5669                    .insert(record_id.clone(), original_record);
5670                self.materializations
5671                    .lock()
5672                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5673                    .insert(id.clone(), original_ownership);
5674                return Err(persistence_failure(error));
5675            }
5676            (terminal_record, cleanup_ownership)
5677        };
5678        let cleanup = self
5679            .session_environment_materializer
5680            .as_ref()
5681            .ok_or(())
5682            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5683        if cleanup.is_err() {
5684            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5685            self.materializations
5686                .lock()
5687                .unwrap_or_else(|poisoned| poisoned.into_inner())
5688                .insert(id.clone(), cleanup_ownership);
5689            if self.persist_state().is_err() {
5690                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5691            }
5692            return Err(failure(
5693                NodeFailureCode::BackendOperationFailed,
5694                "managed session environment cleanup requires recovery",
5695            ));
5696        }
5697        let _transaction = self
5698            .state_transaction
5699            .lock()
5700            .unwrap_or_else(|poisoned| poisoned.into_inner());
5701        self.remove_record_memory(record_id);
5702        self.materializations
5703            .lock()
5704            .unwrap_or_else(|poisoned| poisoned.into_inner())
5705            .remove(&id);
5706        if let Err(error) = self.persist_state_locked() {
5707            self.session_records
5708                .lock()
5709                .unwrap_or_else(|poisoned| poisoned.into_inner())
5710                .records
5711                .insert(record_id.clone(), terminal_record);
5712            self.materializations
5713                .lock()
5714                .unwrap_or_else(|poisoned| poisoned.into_inner())
5715                .insert(id, cleanup_ownership);
5716            return Err(persistence_failure(error));
5717        }
5718        Ok(true)
5719    }
5720
5721    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5722        let changed = {
5723            let mut materializations = self
5724                .materializations
5725                .lock()
5726                .unwrap_or_else(|poisoned| poisoned.into_inner());
5727            let Some(ownership) = materializations.get_mut(id) else {
5728                return;
5729            };
5730            ownership.mark_recovery_required(unix_time_ms()).is_ok()
5731        };
5732        if changed && self.persist_state().is_err() {
5733            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5734        }
5735    }
5736
5737    fn reconcile_materializations(&self) {
5738        let records = self
5739            .materializations
5740            .lock()
5741            .unwrap_or_else(|poisoned| poisoned.into_inner())
5742            .values()
5743            .cloned()
5744            .collect::<Vec<_>>();
5745        for mut ownership in records {
5746            let result = match (ownership.state(), ownership.owner()) {
5747                (MaterializationState::Preparing, _)
5748                | (MaterializationState::CleanupRequired, _) => {
5749                    self.cleanup_materialization(ownership.id())
5750                }
5751                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5752                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5753                        self.materializations
5754                            .lock()
5755                            .unwrap_or_else(|poisoned| poisoned.into_inner())
5756                            .insert(ownership.id().clone(), ownership.clone());
5757                        self.persist_state().map_err(persistence_failure)
5758                    } else {
5759                        Err(failure(
5760                            NodeFailureCode::BackendOperationFailed,
5761                            "session environment reconciliation failed",
5762                        ))
5763                    }
5764                }
5765                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5766                    .session_environment_materializer
5767                    .as_ref()
5768                    .ok_or_else(|| {
5769                        failure(
5770                            NodeFailureCode::BackendOperationFailed,
5771                            "session environment materializer is unavailable",
5772                        )
5773                    })
5774                    .and_then(|materializer| {
5775                        materializer.revalidate(&ownership).map_err(|_| failure(
5776                            NodeFailureCode::BackendOperationFailed,
5777                            "session materialization revalidation failed",
5778                        ))?;
5779                        let record = self.record(record_id)?;
5780                        if record.environment_profile.as_ref() != ownership.environment_profile()
5781                            || record.bundle.as_ref() != ownership.bundle()
5782                            || record.context.as_ref() != ownership.context()
5783                        {
5784                            return Err(failure(
5785                                NodeFailureCode::BackendOperationFailed,
5786                                "session materialization ownership changed after restart",
5787                            ));
5788                        }
5789                        if let Some(receipt) = ownership.bundle() {
5790                            let catalog = self.bundle_catalog.read()
5791                                .unwrap_or_else(|poisoned| poisoned.into_inner());
5792                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5793                                NodeFailureCode::UnknownBundle,
5794                                "session bundle is unavailable after restart",
5795                            ))?;
5796                            if bundle.receipt() != *receipt {
5797                                return Err(failure(
5798                                    NodeFailureCode::BundleBindingMismatch,
5799                                    "session bundle changed after restart",
5800                                ));
5801                            }
5802                            let layout = self.bundle_layout(
5803                                &record.provider,
5804                                record.mode,
5805                                record.environment_profile.as_ref(),
5806                                bundle,
5807                            )?;
5808                            materializer
5809                                .revalidate_bundle(&ownership, bundle, layout)
5810                                .map_err(|_| failure(
5811                                    NodeFailureCode::BundleMaterializationFailed,
5812                                    "session bundle revalidation failed",
5813                                ))?;
5814                        }
5815                        if let Some(receipt) = ownership.context() {
5816                            let pack = materializer
5817                                .revalidate_context(&ownership, receipt)
5818                                .map_err(|_| {
5819                                    failure(
5820                                        NodeFailureCode::ContextPackMaterializationFailed,
5821                                        "session context revalidation failed",
5822                                    )
5823                                })?;
5824                            self.context_catalog
5825                                .write()
5826                                .unwrap_or_else(|poisoned| poisoned.into_inner())
5827                                .insert(pack)
5828                                .map_err(|_| {
5829                                    failure(
5830                                        NodeFailureCode::ContextPackMaterializationFailed,
5831                                        "session context catalog restore failed",
5832                                    )
5833                                })?;
5834                        }
5835                        Ok(())
5836                    }),
5837                (MaterializationState::RecoveryRequired, _) => Ok(()),
5838            };
5839            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5840                let _ = ownership.mark_recovery_required(unix_time_ms());
5841                self.materializations
5842                    .lock()
5843                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5844                    .insert(ownership.id().clone(), ownership);
5845                if self.persist_state().is_err() {
5846                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5847                }
5848            }
5849        }
5850        let materialized_profiles = self
5851            .environment_materialization_profiles
5852            .read()
5853            .unwrap_or_else(|poisoned| poisoned.into_inner())
5854            .keys()
5855            .cloned()
5856            .collect::<Vec<_>>();
5857        let ownership = self
5858            .materializations
5859            .lock()
5860            .unwrap_or_else(|poisoned| poisoned.into_inner())
5861            .values()
5862            .cloned()
5863            .collect::<Vec<_>>();
5864        let changed = {
5865            let mut session_records = self
5866                .session_records
5867                .lock()
5868                .unwrap_or_else(|poisoned| poisoned.into_inner());
5869            let mut changed = false;
5870            for record in session_records.records.values_mut() {
5871                let environment_profile = record.environment_profile.as_ref();
5872                if environment_profile.is_none()
5873                    && record.bundle.is_none()
5874                    && record.context.is_none()
5875                {
5876                    continue;
5877                }
5878                if record.bundle.is_none()
5879                    && environment_profile.is_some_and(|profile| {
5880                        !materialized_profiles.contains(&profile.profile_id)
5881                    })
5882                {
5883                    continue;
5884                }
5885                let has_exact_ownership = ownership.iter().any(|candidate| {
5886                    candidate.state() == MaterializationState::Ready
5887                        && candidate.environment_profile() == environment_profile
5888                        && candidate.bundle() == record.bundle.as_ref()
5889                        && candidate.context() == record.context.as_ref()
5890                        && candidate.owner()
5891                            == &MaterializationOwner::Record {
5892                                record_id: record.record_id.clone(),
5893                            }
5894                });
5895                if !has_exact_ownership {
5896                    record.provider_session = None;
5897                    record.active_session = None;
5898                    record.state = ManagedSessionState::Unavailable;
5899                    record.last_error = Some(if record.bundle.is_some() {
5900                        "bundle-unavailable".to_owned()
5901                    } else if record.context.is_some() {
5902                        "context-unavailable".to_owned()
5903                    } else {
5904                        "environment-profile-unavailable".to_owned()
5905                    });
5906                    record.updated_at_unix_ms = unix_time_ms();
5907                    changed = true;
5908                }
5909            }
5910            changed
5911        };
5912        if changed && self.persist_state().is_err() {
5913            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5914        }
5915    }
5916
5917    fn replay_spawn_spec(
5918        &self,
5919        spec: &SpawnSpec,
5920        now: Instant,
5921    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5922        let mut cache = self
5923            .spawn_idempotency
5924            .lock()
5925            .unwrap_or_else(|poisoned| poisoned.into_inner());
5926        cache.entries.retain(|_, entry| entry.expires_at > now);
5927        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5928            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5929                return Err(failure(
5930                    NodeFailureCode::SpawnIdempotencyConflict,
5931                    "spawn idempotency key was reused across spawn kinds",
5932                ));
5933            };
5934            if existing != spec {
5935                return Err(failure(
5936                    NodeFailureCode::SpawnIdempotencyConflict,
5937                    "spawn idempotency key was reused with a different specification",
5938                ));
5939            }
5940            return Ok(Some(result.clone()));
5941        }
5942        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5943            return Err(failure(
5944                NodeFailureCode::SpawnIdempotencyCapacity,
5945                "spawn idempotency cache reached its bounded capacity",
5946            ));
5947        }
5948        Ok(None)
5949    }
5950
5951    fn remember_spawn_spec(
5952        &self,
5953        spec: SpawnSpec,
5954        result: Result<ResolvedSpawnReceipt, NodeFailure>,
5955        accepted_at: Instant,
5956    ) {
5957        self
5958            .spawn_idempotency
5959            .lock()
5960            .unwrap_or_else(|poisoned| poisoned.into_inner())
5961            .entries
5962            .insert(
5963                spec.idempotency_key.clone(),
5964                SpawnIdempotencyEntry {
5965                    value: SpawnIdempotencyValue::Standard { spec, result },
5966                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5967                },
5968            );
5969    }
5970
5971    fn claim_harness_mcp_spawn(
5972        &self,
5973        _reservation_id: &HarnessMcpReservationId,
5974        _activation_digest: &HarnessMcpActivationDigest,
5975        spec: &SpawnSpec,
5976        now: Instant,
5977    ) -> Result<(), NodeFailure> {
5978        let mut cache = self.spawn_idempotency.lock()
5979            .unwrap_or_else(|poisoned| poisoned.into_inner());
5980        cache.entries.retain(|_, entry| entry.expires_at > now);
5981        if cache.entries.contains_key(&spec.idempotency_key) {
5982            return Err(failure(
5983                NodeFailureCode::SpawnIdempotencyConflict,
5984                "spawn idempotency key was reused across spawn kinds",
5985            ));
5986        }
5987        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5988            return Err(failure(
5989                NodeFailureCode::SpawnIdempotencyCapacity,
5990                "spawn idempotency cache reached its bounded capacity",
5991            ));
5992        }
5993        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5994            value: SpawnIdempotencyValue::HarnessMcp,
5995            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5996        });
5997        Ok(())
5998    }
5999
6000    fn replay_managed_spawn(
6001        &self,
6002        request: &ManagedWorktreeSpawnRequest,
6003        now: Instant,
6004    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6005        let mut cache = self.spawn_idempotency.lock()
6006            .unwrap_or_else(|poisoned| poisoned.into_inner());
6007        cache.entries.retain(|_, entry| entry.expires_at > now);
6008        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6009            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6010                return Err(failure(
6011                    NodeFailureCode::SpawnIdempotencyConflict,
6012                    "spawn idempotency key was reused across spawn kinds",
6013                ));
6014            };
6015            if existing != request {
6016                return Err(failure(
6017                    NodeFailureCode::SpawnIdempotencyConflict,
6018                    "spawn idempotency key was reused with a different managed specification",
6019                ));
6020            }
6021            return Ok(Some(result.clone()));
6022        }
6023        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6024            return Err(failure(
6025                NodeFailureCode::SpawnIdempotencyCapacity,
6026                "spawn idempotency cache reached its bounded capacity",
6027            ));
6028        }
6029        Ok(None)
6030    }
6031
6032    fn remember_managed_spawn(
6033        &self,
6034        request: ManagedWorktreeSpawnRequest,
6035        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6036        accepted_at: Instant,
6037    ) {
6038        self.spawn_idempotency.lock()
6039            .unwrap_or_else(|poisoned| poisoned.into_inner())
6040            .entries
6041            .insert(
6042                request.spawn_spec.idempotency_key.clone(),
6043                SpawnIdempotencyEntry {
6044                    value: SpawnIdempotencyValue::Managed { request, result },
6045                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6046                },
6047            );
6048    }
6049
6050    fn remember_managed_spawn_attempt(
6051        &self,
6052        durable_v2_key: Option<&SpawnIdempotencyKey>,
6053        request: ManagedWorktreeSpawnRequest,
6054        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6055        accepted_at: Instant,
6056    ) {
6057        if durable_v2_key.is_none() {
6058            self.remember_managed_spawn(request, result, accepted_at);
6059        }
6060    }
6061
6062    fn acquire_durable_managed_spawn_v2(
6063        &self,
6064        request: &ManagedWorktreeSpawnRequestV2,
6065        request_digest: String,
6066    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6067        if self.state_path.is_none() {
6068            return Err(failure(
6069                NodeFailureCode::BackendOperationFailed,
6070                "managed worktree V2 requires durable node state",
6071            ));
6072        }
6073        let _transaction = self.state_transaction
6074            .lock()
6075            .unwrap_or_else(|poisoned| poisoned.into_inner());
6076        let key = request.spawn_spec.idempotency_key.clone();
6077        let mut previous = None;
6078        {
6079            let mut replays = self.managed_spawn_replays
6080                .lock()
6081                .unwrap_or_else(|poisoned| poisoned.into_inner());
6082            if let Some(record) = replays.get_mut(&key) {
6083                if record.request_digest != request_digest {
6084                    return Err(failure(
6085                        NodeFailureCode::SpawnIdempotencyConflict,
6086                        "managed worktree V2 idempotency key was reused with a different request",
6087                    ));
6088                }
6089                match &record.state {
6090                    ManagedWorktreeSpawnReplayStateV10::Pending {
6091                        owner_incarnation_id,
6092                        lease_id: None,
6093                    } if owner_incarnation_id != &self.incarnation_id => {
6094                        previous = Some(record.clone());
6095                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6096                            owner_incarnation_id: self.incarnation_id,
6097                            lease_id: None,
6098                        };
6099                        record.updated_at_unix_ms = unix_time_ms();
6100                    }
6101                    ManagedWorktreeSpawnReplayStateV10::Pending {
6102                        lease_id: None,
6103                        ..
6104                    } => {
6105                        return Err(failure(
6106                            NodeFailureCode::BackendBusy,
6107                            "managed worktree V2 reservation is already active",
6108                        ));
6109                    }
6110                    ManagedWorktreeSpawnReplayStateV10::Pending {
6111                        lease_id: Some(_),
6112                        ..
6113                    } => {
6114                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6115                    }
6116                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6117                        return Ok(DurableManagedSpawnReplayDecision::Committed(
6118                            receipt.clone(),
6119                        ));
6120                    }
6121                }
6122            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6123                return Err(failure(
6124                    NodeFailureCode::SpawnIdempotencyCapacity,
6125                    "durable managed worktree replay ledger reached its bounded capacity",
6126                ));
6127            } else {
6128                let now = unix_time_ms();
6129                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6130                    idempotency_key: key.clone(),
6131                    request_digest,
6132                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6133                    profile_id: request.worktree_profile_id.clone(),
6134                    expected_profile_revision: request.expected_profile_revision.clone(),
6135                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
6136                        owner_incarnation_id: self.incarnation_id,
6137                        lease_id: None,
6138                    },
6139                    created_at_unix_ms: now,
6140                    updated_at_unix_ms: now,
6141                });
6142            }
6143        }
6144        if let Err(error) = self.persist_state_locked() {
6145            let mut replays = self.managed_spawn_replays
6146                .lock()
6147                .unwrap_or_else(|poisoned| poisoned.into_inner());
6148            match previous {
6149                Some(previous) => {
6150                    replays.insert(key, previous);
6151                }
6152                None => {
6153                    replays.remove(&key);
6154                }
6155            }
6156            return Err(persistence_failure(error));
6157        }
6158        Ok(DurableManagedSpawnReplayDecision::Reserved)
6159    }
6160
6161    fn link_durable_managed_spawn_v2_lease_locked(
6162        &self,
6163        key: &SpawnIdempotencyKey,
6164        lease_id: &ManagedWorktreeLeaseId,
6165    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6166        let mut replays = self.managed_spawn_replays
6167            .lock()
6168            .unwrap_or_else(|poisoned| poisoned.into_inner());
6169        let record = replays.get_mut(key).ok_or_else(|| {
6170            failure(
6171                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6172                "managed worktree V2 reservation disappeared before allocation",
6173            )
6174        })?;
6175        let previous = record.clone();
6176        match &mut record.state {
6177            ManagedWorktreeSpawnReplayStateV10::Pending {
6178                lease_id: current,
6179                ..
6180            }
6181                if current.is_none() =>
6182            {
6183                *current = Some(lease_id.clone());
6184                record.updated_at_unix_ms = unix_time_ms();
6185                Ok(previous)
6186            }
6187            _ => Err(failure(
6188                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6189                "managed worktree V2 reservation is not allocation-ready",
6190            )),
6191        }
6192    }
6193
6194    fn restore_durable_managed_spawn_v2_locked(
6195        &self,
6196        key: &SpawnIdempotencyKey,
6197        previous: ManagedWorktreeSpawnReplayRecordV10,
6198    ) {
6199        self.managed_spawn_replays
6200            .lock()
6201            .unwrap_or_else(|poisoned| poisoned.into_inner())
6202            .insert(key.clone(), previous);
6203    }
6204
6205    fn commit_durable_managed_spawn_v2(
6206        &self,
6207        request: &ManagedWorktreeSpawnRequestV2,
6208        receipt: ManagedWorktreeSpawnReceipt,
6209    ) -> Result<(), NodeFailure> {
6210        let _transaction = self.state_transaction
6211            .lock()
6212            .unwrap_or_else(|poisoned| poisoned.into_inner());
6213        let key = &request.spawn_spec.idempotency_key;
6214        let previous = {
6215            let mut replays = self.managed_spawn_replays
6216                .lock()
6217                .unwrap_or_else(|poisoned| poisoned.into_inner());
6218            let record = replays.get_mut(key).ok_or_else(|| {
6219                failure(
6220                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6221                    "managed worktree V2 reservation disappeared before commit",
6222                )
6223            })?;
6224            let previous = record.clone();
6225            match &record.state {
6226                ManagedWorktreeSpawnReplayStateV10::Pending {
6227                    lease_id: Some(lease_id),
6228                    ..
6229                } if lease_id == &receipt.lease.lease_id => {}
6230                _ => {
6231                    return Err(failure(
6232                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
6233                        "managed worktree V2 reservation is not linked to the spawned lease",
6234                    ));
6235                }
6236            }
6237            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6238            record.updated_at_unix_ms = unix_time_ms();
6239            previous
6240        };
6241        if let Err(error) = self.persist_state_locked() {
6242            self.restore_durable_managed_spawn_v2_locked(key, previous);
6243            return Err(persistence_failure(error));
6244        }
6245        Ok(())
6246    }
6247
6248    fn remove_unallocated_durable_managed_spawn_v2(
6249        &self,
6250        request: &ManagedWorktreeSpawnRequestV2,
6251    ) -> Result<(), NodeFailure> {
6252        let _transaction = self.state_transaction
6253            .lock()
6254            .unwrap_or_else(|poisoned| poisoned.into_inner());
6255        let key = &request.spawn_spec.idempotency_key;
6256        let removed = {
6257            let mut replays = self.managed_spawn_replays
6258                .lock()
6259                .unwrap_or_else(|poisoned| poisoned.into_inner());
6260            match replays.get(key).map(|record| &record.state) {
6261                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6262                    lease_id: None,
6263                    ..
6264                }) => {
6265                    replays.remove(key)
6266                }
6267                _ => None,
6268            }
6269        };
6270        let Some(removed) = removed else {
6271            return Ok(());
6272        };
6273        if let Err(error) = self.persist_state_locked() {
6274            self.managed_spawn_replays
6275                .lock()
6276                .unwrap_or_else(|poisoned| poisoned.into_inner())
6277                .insert(key.clone(), removed);
6278            return Err(persistence_failure(error));
6279        }
6280        Ok(())
6281    }
6282
6283    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6284        if spec.target.node_id != self.node_id {
6285            return Err(failure(
6286                NodeFailureCode::SpawnTargetMismatch,
6287                "spawn target does not match this node",
6288            ));
6289        }
6290        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6291            failure(
6292                NodeFailureCode::UnknownSpawnProfile,
6293                "spawn profile is unavailable on this node",
6294            )
6295        })?;
6296        let resolved = spec.resolve(defaults).map_err(|error| match error {
6297            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6298                NodeFailureCode::SpawnProfileRevisionMismatch,
6299                "spawn profile revision does not match the loaded profile",
6300            ),
6301            _ => failure(
6302                NodeFailureCode::InvalidRequest,
6303                "spawn specification could not be resolved",
6304            ),
6305        })?;
6306        if resolved.target.worktree_id.is_some() {
6307            self.require_worktree_service(&resolved.target.workspace_id)?;
6308        }
6309        let environment_profile = self.resolve_environment_profile(&resolved)?;
6310        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6311        self.resolve_context(&resolved)?;
6312        Ok(resolved)
6313    }
6314
6315    async fn resolve_spawn_workspace(
6316        &self,
6317        resolved: &ResolvedSpawnSpec,
6318        deadline: Instant,
6319    ) -> Result<WorkspaceId, NodeFailure> {
6320        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6321            return Ok(resolved.target.workspace_id.clone());
6322        };
6323        if self.managed_worktrees.lock()
6324            .unwrap_or_else(|poisoned| poisoned.into_inner())
6325            .lease_for_workspace(worktree_id)
6326            .is_some()
6327        {
6328            return Err(failure(
6329                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6330                "managed worktree targets may only be spawned through their lease request",
6331            ));
6332        }
6333        self.require_worktree_service(&resolved.target.workspace_id)?;
6334        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6335        let selected_root = self.workspace_root(worktree_id)?;
6336        let remaining = spawn_deadline_remaining(deadline)?;
6337        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6338            .await
6339            .map_err(|_| {
6340                failure(
6341                    NodeFailureCode::SpawnDeadlineExceeded,
6342                    "Git worktree selection exceeded the spawn deadline",
6343                )
6344            })?
6345            .map_err(git_worktree_failure)?;
6346        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6347        Ok(worktree_id.clone())
6348    }
6349
6350    async fn spawn_from_spec(
6351        &self,
6352        spec: SpawnSpec,
6353    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6354        self.spawn_from_spec_with_proxy(spec, None).await
6355    }
6356
6357    async fn spawn_from_spec_with_proxy(
6358        &self,
6359        spec: SpawnSpec,
6360        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6361    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6362        let accepted_at = Instant::now();
6363        if harness_mcp.is_none() {
6364            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6365                return replayed;
6366            }
6367        }
6368        let resolved = self.resolve_spawn_spec(&spec)?;
6369        let environment_profile = self.resolve_environment_profile(&resolved)?;
6370        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6371        let context = self.resolve_context(&resolved)?;
6372        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6373        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6374        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6375        let spawn_workspace_id = match spawn_workspace_id {
6376            Ok(workspace_id) => workspace_id,
6377            Err(error) => {
6378                let result = Err(error);
6379                if harness_mcp.is_none() {
6380                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6381                }
6382                return result;
6383            }
6384        };
6385        if self.managed_worktrees.lock()
6386            .unwrap_or_else(|poisoned| poisoned.into_inner())
6387            .lease_for_workspace(&spawn_workspace_id)
6388            .is_some()
6389        {
6390            let result = Err(failure(
6391                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6392                "managed worktree targets may only be spawned through their lease request",
6393            ));
6394            if harness_mcp.is_none() {
6395                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6396            }
6397            return result;
6398        }
6399        let result = self
6400            .spawn_session_with_deadline(
6401                spawn_workspace_id,
6402                resolved.provider.clone(),
6403                resolved.mode,
6404                resolved.terminal_size,
6405                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6406                environment_profile.clone(),
6407                bundle.clone(),
6408                context.clone(),
6409                None,
6410                None,
6411                SpawnRecordPolicy::Always,
6412                Some(deadline),
6413                &required_capabilities,
6414                harness_mcp,
6415                resolved.approval_level,
6416                resolved.network_allowlist.clone(),
6417                resolved.browser_profile_id.clone(),
6418            )
6419            .await
6420            .map(|(session, _runtime_policy)| {
6421                resolved.receipt_with_materialization(
6422                    self.incarnation_id,
6423                    session,
6424                    environment_profile,
6425                    bundle,
6426                    context,
6427                )
6428            });
6429        if harness_mcp.is_none() {
6430            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6431        }
6432        result
6433    }
6434
6435    async fn spawn_from_spec_with_harness_mcp(
6436        &self,
6437        reservation_id: HarnessMcpReservationId,
6438        activation_digest: HarnessMcpActivationDigest,
6439        spec: SpawnSpec,
6440        deadline_unix_ms: u64,
6441    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6442        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6443            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6444        })?;
6445        if let Some(receipt) = registry
6446            .replay_spawn(&reservation_id, &activation_digest, &spec)
6447            .map_err(harness_mcp_failure)?
6448        {
6449            return Ok(receipt);
6450        }
6451        let resolved = self.resolve_spawn_spec(&spec)?;
6452        let prepared = registry
6453            .prepare_spawn(
6454                &reservation_id,
6455                &activation_digest,
6456                &spec,
6457                deadline_unix_ms,
6458                resolved.provider.clone(),
6459            )
6460            .map_err(harness_mcp_failure)?;
6461        self.claim_harness_mcp_spawn(
6462            &reservation_id,
6463            &activation_digest,
6464            &spec,
6465            Instant::now(),
6466        )?;
6467        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6468        let mut receipt = match spawn {
6469            Ok(receipt) => receipt,
6470            Err(error) => {
6471                let _ = registry.abort(&reservation_id, &activation_digest);
6472                return Err(error);
6473            }
6474        };
6475        let record_id = self
6476            .session_bindings
6477            .lock()
6478            .unwrap_or_else(|poisoned| poisoned.into_inner())
6479            .get(&receipt.session.session.instance_id)
6480            .and_then(|binding| binding.record_id.clone());
6481        let record_id = match record_id {
6482            Some(record_id) => record_id,
6483            None => {
6484                let rollback = self.remove_session(&receipt.session).await;
6485                let _ = registry.abort(&reservation_id, &activation_digest);
6486                if let Err(rollback_error) = rollback {
6487                    return Err(rollback_error);
6488                }
6489                return Err(failure(
6490                    NodeFailureCode::BindingMismatch,
6491                    "harness MCP spawn did not retain its exact managed record",
6492                ));
6493            }
6494        };
6495        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6496            reservation_id: reservation_id.clone(),
6497            activation_digest: activation_digest.clone(),
6498        });
6499        if let Err(error) = registry.mark_spawned(
6500            &prepared,
6501            receipt.session.clone(),
6502            record_id,
6503            receipt.clone(),
6504        ) {
6505            let rollback = self.remove_session(&receipt.session).await;
6506            let _ = registry.abort(&reservation_id, &activation_digest);
6507            if let Err(rollback_error) = rollback {
6508                return Err(rollback_error);
6509            }
6510            return Err(harness_mcp_failure(error));
6511        }
6512        Ok(receipt)
6513    }
6514
6515    async fn spawn_managed_worktree(
6516        &self,
6517        request: ManagedWorktreeSpawnRequest,
6518    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6519        self.spawn_managed_worktree_inner(request, None).await
6520    }
6521
6522    async fn spawn_managed_worktree_inner(
6523        &self,
6524        request: ManagedWorktreeSpawnRequest,
6525        durable_v2_key: Option<&SpawnIdempotencyKey>,
6526    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6527        let accepted_at = Instant::now();
6528        if durable_v2_key.is_none() {
6529            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6530                return replayed;
6531            }
6532        }
6533        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6534        if request.spawn_spec.target.worktree_id.is_some() {
6535            let result = Err(failure(
6536                NodeFailureCode::InvalidRequest,
6537                "managed worktree spawn must not select a caller-provided worktree",
6538            ));
6539            self.remember_managed_spawn_attempt(
6540                durable_v2_key,
6541                request,
6542                result.clone(),
6543                accepted_at,
6544            );
6545            return result;
6546        }
6547        let environment_profile = self.resolve_environment_profile(&resolved)?;
6548        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6549        let context = self.resolve_context(&resolved)?;
6550        let source_workspace_id = resolved.target.workspace_id.clone();
6551        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6552        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6553        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6554        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6555            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6556            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6557            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6558            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6559        };
6560        let runtime_policy = timeout(
6561            spawn_deadline_remaining(deadline)?,
6562            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6563        ).await.map_err(|_| failure(
6564            NodeFailureCode::SpawnDeadlineExceeded,
6565            "provider admission exceeded the managed spawn deadline",
6566        ))??;
6567        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6568            return Err(failure(
6569                NodeFailureCode::UnsupportedSpawnCapability,
6570                "provider runtime does not admit a required spawn capability",
6571            ));
6572        }
6573        self.ensure_binding_capacity()?;
6574        let source_root = self.workspace_root(&source_workspace_id)?;
6575        profile.validate_for_workspace(&source_root)
6576            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6577        let base_timeout_ms = u64::try_from(
6578            spawn_deadline_remaining(deadline)?.as_millis(),
6579        ).unwrap_or(u64::MAX).max(1);
6580        let base_commit = resolve_base_commit_with_timeout(
6581            &source_root,
6582            profile.base(),
6583            base_timeout_ms,
6584        ).await.map_err(|error| {
6585            if Instant::now() >= deadline || error.message.contains("timed out") {
6586                failure(
6587                    NodeFailureCode::SpawnDeadlineExceeded,
6588                    "managed worktree base resolution exceeded the spawn deadline",
6589                )
6590            } else {
6591                managed_git_worktree_failure(error)
6592            }
6593        })?;
6594
6595        let lease = {
6596            let _transaction = self.state_transaction.lock()
6597                .unwrap_or_else(|poisoned| poisoned.into_inner());
6598            let previous = self.managed_worktrees.lock()
6599                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6600            let lease = self.managed_worktrees.lock()
6601                .unwrap_or_else(|poisoned| poisoned.into_inner())
6602                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6603                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6604            let previous_replay = match durable_v2_key
6605                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6606                .transpose()
6607            {
6608                Ok(previous) => previous,
6609                Err(error) => {
6610                    *self.managed_worktrees.lock()
6611                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6612                    return Err(error);
6613                }
6614            };
6615            if let Err(error) = self.persist_state_locked() {
6616                *self.managed_worktrees.lock()
6617                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6618                if let (Some(key), Some(previous_replay)) =
6619                    (durable_v2_key, previous_replay)
6620                {
6621                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6622                }
6623                return Err(persistence_failure(error));
6624            }
6625            lease
6626        };
6627        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6628        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6629            self.managed_worktrees.lock()
6630                .unwrap_or_else(|poisoned| poisoned.into_inner())
6631                .remove_unmutated(&lease.lease_id);
6632            if self.persist_state().is_err() {
6633                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6634            }
6635            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6636            self.remember_managed_spawn_attempt(
6637                durable_v2_key,
6638                request,
6639                result.clone(),
6640                accepted_at,
6641            );
6642            return result;
6643        }
6644        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6645            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6646            Err(error) => {
6647                self.reconcile_failed_allocation(&lease, &source_root).await;
6648                let result = Err(error);
6649                self.remember_managed_spawn_attempt(
6650                    durable_v2_key,
6651                    request,
6652                    result.clone(),
6653                    accepted_at,
6654                );
6655                return result;
6656            }
6657        };
6658        let created = match create_git_worktree_with_timeout(
6659                &source_root,
6660                &lease.target_root,
6661                &lease.branch,
6662                Some(&base_commit),
6663                mutation_timeout_ms,
6664            ).await {
6665            Ok(created) => created,
6666            Err(error) => {
6667                self.reconcile_failed_allocation(&lease, &source_root).await;
6668                let result = Err(if Instant::now() >= deadline
6669                    || error.message.contains("timed out")
6670                    || error.message.contains("deadline")
6671                {
6672                    failure(
6673                        NodeFailureCode::SpawnDeadlineExceeded,
6674                        "managed worktree creation exceeded the spawn deadline",
6675                    )
6676                } else {
6677                    managed_git_worktree_failure(error)
6678                });
6679                self.remember_managed_spawn_attempt(
6680                    durable_v2_key,
6681                    request,
6682                    result.clone(),
6683                    accepted_at,
6684                );
6685                return result;
6686            }
6687        };
6688        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6689            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6690            let result = Err(failure(
6691                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6692                "created worktree identity did not match its durable lease",
6693            ));
6694            self.remember_managed_spawn_attempt(
6695                durable_v2_key,
6696                request,
6697                result.clone(),
6698                accepted_at,
6699            );
6700            return result;
6701        }
6702        {
6703            let mut registry = self.managed_worktrees.lock()
6704                .unwrap_or_else(|poisoned| poisoned.into_inner());
6705            let current = registry.get_mut(&lease.lease_id)
6706                .expect("allocated managed worktree lease remains present");
6707            current.expected_head = Some(created.head.clone());
6708            current.state = ManagedWorktreeLeaseState::Ready;
6709            current.updated_at_unix_ms = unix_time_ms();
6710        }
6711        if let Err(error) = self.persist_state() {
6712            self.mark_managed_recovery_required(
6713                &lease.lease_id,
6714                ManagedWorktreeCleanupFailure::Backend,
6715            );
6716            let result = Err(persistence_failure(error));
6717            self.remember_managed_spawn_attempt(
6718                durable_v2_key,
6719                request,
6720                result.clone(),
6721                accepted_at,
6722            );
6723            return result;
6724        }
6725        let ready = self.managed_worktrees.lock()
6726            .unwrap_or_else(|poisoned| poisoned.into_inner())
6727            .get(&lease.lease_id)
6728            .expect("ready managed worktree lease remains present")
6729            .snapshot();
6730        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6731        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6732            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6733            let result = Err(error);
6734            self.remember_managed_spawn_attempt(
6735                durable_v2_key,
6736                request,
6737                result.clone(),
6738                accepted_at,
6739            );
6740            return result;
6741        }
6742        let spawn = self.spawn_session_with_deadline(
6743            lease.workspace_id.clone(),
6744            resolved.provider.clone(),
6745            resolved.mode,
6746            resolved.terminal_size,
6747            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6748            environment_profile.clone(),
6749            bundle.clone(),
6750            context.clone(),
6751            Some(lease.lease_id.clone()),
6752            Some(runtime_policy),
6753            SpawnRecordPolicy::Always,
6754            Some(deadline),
6755            &required_capabilities,
6756            None,
6757            resolved.approval_level,
6758            resolved.network_allowlist.clone(),
6759            resolved.browser_profile_id.clone(),
6760        ).await;
6761        let (session, effective_runtime_policy) = match spawn {
6762            Ok(spawned) => spawned,
6763            Err(error) => {
6764                if self
6765                    .cleanup_managed_worktree(&lease.lease_id, true)
6766                    .await
6767                    .is_err()
6768                {
6769                    self.mark_managed_recovery_required(
6770                        &lease.lease_id,
6771                        ManagedWorktreeCleanupFailure::Backend,
6772                    );
6773                }
6774                let result = Err(error);
6775                self.remember_managed_spawn_attempt(
6776                    durable_v2_key,
6777                    request,
6778                    result.clone(),
6779                    accepted_at,
6780                );
6781                return result;
6782            }
6783        };
6784        let (record_id, raw_inventory_record_id) = {
6785            let mut bindings = self.session_bindings.lock()
6786                .unwrap_or_else(|poisoned| poisoned.into_inner());
6787            let binding = bindings.get_mut(&session.session.instance_id)
6788                .expect("successful spawn retains its session binding");
6789            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6790            managed_spawn_record_ownership(binding, effective_runtime_policy)
6791        };
6792        let bound = self.managed_worktrees.lock()
6793            .unwrap_or_else(|poisoned| poisoned.into_inner())
6794            .bind_session(
6795                &lease.lease_id,
6796                ManagedWorktreeSessionHolder {
6797                    incarnation_id: self.incarnation_id,
6798                    instance_id: session.session.instance_id,
6799                    generation: session.session.generation,
6800                },
6801                record_id,
6802                unix_time_ms(),
6803            )
6804            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6805        let snapshot = match bound {
6806            Ok(snapshot) => snapshot,
6807            Err(error) => {
6808                if raw_inventory_record_id
6809                    .as_ref()
6810                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
6811                {
6812                    self.mark_managed_recovery_required(
6813                        &lease.lease_id,
6814                        ManagedWorktreeCleanupFailure::Backend,
6815                    );
6816                }
6817                let _ = self.remove_session(&session).await;
6818                self.mark_managed_recovery_required(
6819                    &lease.lease_id,
6820                    ManagedWorktreeCleanupFailure::Backend,
6821                );
6822                let result = Err(error);
6823                self.remember_managed_spawn_attempt(
6824                    durable_v2_key,
6825                    request,
6826                    result.clone(),
6827                    accepted_at,
6828                );
6829                return result;
6830            }
6831        };
6832        if let Err(error) = self.persist_state() {
6833            if raw_inventory_record_id
6834                .as_ref()
6835                .is_some_and(|record_id| self.discard_record(record_id).is_err())
6836            {
6837                self.mark_managed_recovery_required(
6838                    &lease.lease_id,
6839                    ManagedWorktreeCleanupFailure::Backend,
6840                );
6841            }
6842            let _ = self.remove_session(&session).await;
6843            if self
6844                .cleanup_managed_worktree(&lease.lease_id, true)
6845                .await
6846                .is_err()
6847            {
6848                self.mark_managed_recovery_required(
6849                    &lease.lease_id,
6850                    ManagedWorktreeCleanupFailure::Backend,
6851                );
6852            }
6853            let result = Err(persistence_failure(error));
6854            self.remember_managed_spawn_attempt(
6855                durable_v2_key,
6856                request,
6857                result.clone(),
6858                accepted_at,
6859            );
6860            return result;
6861        }
6862        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6863        let receipt = managed_spawn_receipt(
6864            &resolved,
6865            self.incarnation_id,
6866            session,
6867            snapshot,
6868            environment_profile,
6869            bundle,
6870            context,
6871        );
6872        let result = Ok(receipt);
6873        self.remember_managed_spawn_attempt(
6874            durable_v2_key,
6875            request,
6876            result.clone(),
6877            accepted_at,
6878        );
6879        result
6880    }
6881
6882    async fn spawn_managed_worktree_v2(
6883        &self,
6884        request: ManagedWorktreeSpawnRequestV2,
6885    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6886        if request.spawn_spec.target.node_id != self.node_id {
6887            return Err(failure(
6888                NodeFailureCode::SpawnTargetMismatch,
6889                "spawn target does not match this node",
6890            ));
6891        }
6892        if request.spawn_spec.target.worktree_id.is_some() {
6893            return Err(failure(
6894                NodeFailureCode::InvalidRequest,
6895                "managed worktree spawn must not select a caller-provided worktree",
6896            ));
6897        }
6898        let request_digest = managed_spawn_request_digest_v2(&request)?;
6899        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6900        match replay {
6901            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6902            DurableManagedSpawnReplayDecision::PendingLinked => {
6903                return Err(failure(
6904                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6905                    "managed worktree V2 reservation has an incomplete durable allocation",
6906                ));
6907            }
6908            DurableManagedSpawnReplayDecision::Reserved => {}
6909        }
6910        let validated = (|| {
6911            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6912            let profile = self.managed_profile(
6913                &resolved.target.workspace_id,
6914                &request.worktree_profile_id,
6915            )?;
6916            if profile.revision() != &request.expected_profile_revision {
6917                return Err(failure(
6918                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6919                    "managed worktree profile revision does not match the loaded profile",
6920                ));
6921            }
6922            Ok(())
6923        })();
6924        match validated {
6925            Ok(()) => {}
6926            Err(error) => {
6927                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6928                return Err(error);
6929            }
6930        }
6931        let legacy = ManagedWorktreeSpawnRequest {
6932            spawn_spec: request.spawn_spec.clone(),
6933            worktree_profile_id: request.worktree_profile_id.clone(),
6934        };
6935        match self
6936            .spawn_managed_worktree_inner(
6937                legacy,
6938                Some(&request.spawn_spec.idempotency_key),
6939            )
6940            .await
6941        {
6942            Ok(receipt) => {
6943                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6944                Ok(receipt)
6945            }
6946            Err(error) => {
6947                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6948                Err(error)
6949            }
6950        }
6951    }
6952
6953    fn managed_profile(
6954        &self,
6955        workspace_id: &WorkspaceId,
6956        profile_id: &WorktreeProfileId,
6957    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6958        if self.worktree_service_modes.get(workspace_id).copied()
6959            != Some(WorktreeServiceMode::Managed)
6960        {
6961            return Err(failure(
6962                NodeFailureCode::UnsupportedSpawnCapability,
6963                "workspace does not enable managed worktrees",
6964            ));
6965        }
6966        self.managed_worktree_profiles.get(workspace_id)
6967            .and_then(|profiles| profiles.get(profile_id))
6968            .cloned()
6969            .ok_or_else(|| failure(
6970                NodeFailureCode::InvalidRequest,
6971                "managed worktree profile is unavailable for this workspace",
6972            ))
6973    }
6974
6975    async fn reconcile_failed_allocation(
6976        &self,
6977        lease: &ManagedWorktreeLeaseRecord,
6978        source_root: &str,
6979    ) {
6980        match list_git_worktrees(source_root).await {
6981            Ok(worktrees) => {
6982                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6983                    None => {
6984                        match std::fs::symlink_metadata(&lease.target_root) {
6985                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
6986                                self.managed_worktrees.lock()
6987                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6988                                    .remove_unmutated(&lease.lease_id);
6989                                let _ = self.persist_state();
6990                            }
6991                            _ => self.mark_managed_recovery_required(
6992                                &lease.lease_id,
6993                                ManagedWorktreeCleanupFailure::OwnershipConflict,
6994                            ),
6995                        }
6996                    }
6997                    Some(item) if exact_owned_worktree(
6998                        lease,
6999                        &item.path,
7000                        item.branch.as_deref(),
7001                        &item.head,
7002                    ) => {
7003                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7004                    }
7005                    Some(_) => self.mark_managed_recovery_required(
7006                        &lease.lease_id,
7007                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7008                    ),
7009                }
7010            }
7011            Err(_) => self.mark_managed_recovery_required(
7012                &lease.lease_id,
7013                ManagedWorktreeCleanupFailure::Backend,
7014            ),
7015        }
7016    }
7017
7018    async fn reconcile_managed_worktrees(&self) {
7019        let leases = self.managed_worktrees.lock()
7020            .unwrap_or_else(|poisoned| poisoned.into_inner())
7021            .active_records()
7022            .cloned()
7023            .collect::<Vec<_>>();
7024        for lease in leases {
7025            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7026                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7027                _ => {
7028                    self.mark_managed_recovery_required(
7029                        &lease.lease_id,
7030                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7031                    );
7032                    continue;
7033                }
7034            };
7035            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7036                self.mark_managed_recovery_required(
7037                    &lease.lease_id,
7038                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7039                );
7040                continue;
7041            }
7042            let source_root = match self.workspace_root(&lease.source_workspace_id) {
7043                Ok(root) => root,
7044                Err(_) => {
7045                    self.mark_managed_recovery_required(
7046                        &lease.lease_id,
7047                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7048                    );
7049                    continue;
7050                }
7051            };
7052            let worktrees = match list_git_worktrees(&source_root).await {
7053                Ok(worktrees) => worktrees,
7054                Err(_) => {
7055                    self.mark_managed_recovery_required(
7056                        &lease.lease_id,
7057                        ManagedWorktreeCleanupFailure::Backend,
7058                    );
7059                    continue;
7060                }
7061            };
7062            let listed = worktrees.iter()
7063                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7064            let Some(listed) = listed else {
7065                match std::fs::symlink_metadata(&lease.target_root) {
7066                    Err(error) if error.kind() == io::ErrorKind::NotFound
7067                        && lease.state == ManagedWorktreeLeaseState::Allocating
7068                        && !lease.has_holders() => {
7069                            let removed = self.managed_worktrees.lock()
7070                                .unwrap_or_else(|poisoned| poisoned.into_inner())
7071                                .tombstone(&lease.lease_id, unix_time_ms());
7072                            if self.persist_state().is_ok() && removed.is_some() {
7073                                self.publish(NodeEvent::ManagedWorktreeRemoved {
7074                                    lease_id: lease.lease_id.clone(),
7075                                });
7076                            }
7077                        }
7078                    _ => self.mark_managed_recovery_required(
7079                        &lease.lease_id,
7080                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7081                    ),
7082                }
7083                continue;
7084            };
7085            if listed.is_main || listed.is_bare || !exact_owned_worktree(
7086                &lease,
7087                &listed.path,
7088                listed.branch.as_deref(),
7089                &listed.head,
7090            ) {
7091                self.mark_managed_recovery_required(
7092                    &lease.lease_id,
7093                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7094                );
7095                continue;
7096            }
7097            if !self.workspaces.read()
7098                .unwrap_or_else(|poisoned| poisoned.into_inner())
7099                .contains_key(&lease.workspace_id)
7100            {
7101                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7102                    self.mark_managed_recovery_required(
7103                        &lease.lease_id,
7104                        ManagedWorktreeCleanupFailure::Backend,
7105                    );
7106                    continue;
7107                }
7108            }
7109            let snapshot = {
7110                let mut registry = self.managed_worktrees.lock()
7111                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7112                let current = registry.get_mut(&lease.lease_id)
7113                    .expect("reconciled managed lease remains present");
7114                current.expected_head = Some(listed.head.clone());
7115                current.cleanup_failure = None;
7116                current.state = if current.has_holders() {
7117                    ManagedWorktreeLeaseState::InUse
7118                } else if current.retention == ManagedWorktreeRetention::Retain {
7119                    ManagedWorktreeLeaseState::Retained
7120                } else {
7121                    ManagedWorktreeLeaseState::Ready
7122                };
7123                current.updated_at_unix_ms = unix_time_ms();
7124                current.snapshot()
7125            };
7126            if self.persist_state().is_err() {
7127                self.mark_managed_recovery_required(
7128                    &lease.lease_id,
7129                    ManagedWorktreeCleanupFailure::Backend,
7130                );
7131                continue;
7132            }
7133            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7134            if snapshot.state == ManagedWorktreeLeaseState::Ready {
7135                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7136            }
7137        }
7138    }
7139
7140    /// Crash-recovery startup sweep: durably capture the pack for every
7141    /// `Live` managed record whose most recent session has not yet produced
7142    /// one (recovers a session whose `Exited{0}` event was observed but the
7143    /// Node crashed before the export finished). The reactive fast path for
7144    /// a single just-exited record does not go through this — see
7145    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
7146    /// it can capture the exact `SessionAddress` before `publish_control`
7147    /// clears it, rather than re-deriving it from (by then likely already
7148    /// `Dormant`) record state the way a rescan naturally would.
7149    ///
7150    /// Returns as soon as every eligible record's export has been *spawned*,
7151    /// not completed — each one runs on its own detached task (see
7152    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
7153    /// `NativeRuntime::tick()` has not even started its own loop yet at the
7154    /// point this is called from `NodeServer::run()`, so nothing could ever
7155    /// drain the history round trip if this awaited it inline here either).
7156    /// Best-effort throughout: never panics, no unwrap/expect; every failure
7157    /// is a silent skip for THIS record — an unusable source or a racing
7158    /// record is not surfaced to any caller.
7159    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7160        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7161            .session_records
7162            .lock()
7163            .unwrap_or_else(|poisoned| poisoned.into_inner())
7164            .records
7165            .values()
7166            .filter(|record| {
7167                record.state == ManagedSessionState::Live
7168                    && record.exported_context.is_none()
7169                    && record.provider_session.is_some()
7170            })
7171            .filter_map(|record| {
7172                record
7173                    .active_session
7174                    .clone()
7175                    .map(|session| (record.record_id.clone(), session))
7176            })
7177            .collect();
7178        for (record_id, session) in candidates {
7179            let shared = Arc::clone(self);
7180            tokio::spawn(async move {
7181                shared
7182                    .reconcile_context_pack_export_for_record(&record_id, session)
7183                    .await;
7184            });
7185        }
7186    }
7187
7188    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
7189    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
7190    /// round trip this performs (`discover_history`/`load_history`, via
7191    /// `export_context_pack_for_session_record`) can only ever settle once
7192    /// `NativeRuntime::tick()` runs again to drain the authority worker's
7193    /// completion — and `tick()` is exclusively owned by, and only ever
7194    /// called from, that same drive-loop task. Awaiting this inline there
7195    /// would starve `tick()` and this call could never observe its own
7196    /// command complete.
7197    ///
7198    /// Because this now genuinely races the drive loop's own
7199    /// `publish_control` (which downgrades this exact record from `Live` to
7200    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
7201    /// triggered this reconcile), the record legitimately may already be
7202    /// `Dormant` by the time this reads it, or may transition mid-flight —
7203    /// `allow_clean_detachment: true` on the downstream export call is what
7204    /// tolerates exactly that transition (and only that transition; see
7205    /// `session_record_export_target_matches`).
7206    async fn reconcile_context_pack_export_for_record(
7207        &self,
7208        record_id: &SessionRecordId,
7209        session: SessionAddress,
7210    ) {
7211        let Ok(record) = self.record(record_id) else {
7212            return;
7213        };
7214        if !matches!(
7215            record.state,
7216            ManagedSessionState::Live | ManagedSessionState::Dormant
7217        ) || record.exported_context.is_some()
7218            || record.provider_session.is_none()
7219        {
7220            return;
7221        }
7222        // Pack bytes are already durably committed inside this call, via
7223        // commit_context_pack_for_session_record's own durable
7224        // write-through (context_pack_store) — no separate step here.
7225        // Unclean exits (non-zero/forced/no loadable history) are already
7226        // rejected by context_pack_source_status_is_usable inside
7227        // materialize_context_pack: this simply returns Err and is skipped.
7228        let Ok(receipt) = self
7229            .export_context_pack_for_session_record(&record.record_id, &session, true)
7230            .await
7231        else {
7232            return;
7233        };
7234        self.set_exported_context(&record.record_id, receipt);
7235    }
7236
7237    /// Records the record's most recent clean-exit pack. No-op (not an
7238    /// error) if the record disappeared, or if it already carries an
7239    /// `exported_context` — first-write-wins, matching the harness-side
7240    /// idempotent reconciliation that consumes this field.
7241    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7242        let transaction = self
7243            .state_transaction
7244            .lock()
7245            .unwrap_or_else(|poisoned| poisoned.into_inner());
7246        let (previous, updated) = {
7247            let mut records = self
7248                .session_records
7249                .lock()
7250                .unwrap_or_else(|poisoned| poisoned.into_inner());
7251            let Some(record) = records.records.get_mut(record_id) else {
7252                return;
7253            };
7254            if record.exported_context.is_some() {
7255                return;
7256            }
7257            let previous = record.clone();
7258            record.exported_context = Some(receipt);
7259            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7260            (previous, record.clone())
7261        };
7262        if self.persist_state_locked().is_err() {
7263            self.session_records
7264                .lock()
7265                .unwrap_or_else(|poisoned| poisoned.into_inner())
7266                .records
7267                .insert(record_id.clone(), previous);
7268            return;
7269        }
7270        drop(transaction);
7271        self.publish_record(updated);
7272    }
7273
7274    /// Resolves the managed record and its exact `SessionAddress` currently
7275    /// bound to `instance_id` at exactly `generation`, used by the reactive
7276    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
7277    /// record without a full rescan. Mirrors the lookup already inlined in
7278    /// `publish_control`. Captured synchronously, before `publish_control`
7279    /// for the same event clears the record's own `active_session` — the
7280    /// caller passes the resolved address through to a detached
7281    /// reconciliation task rather than re-deriving it from the record later,
7282    /// after that race has necessarily already happened.
7283    fn managed_record_export_target_for_instance(
7284        &self,
7285        instance_id: AgentInstanceId,
7286        generation: SessionGeneration,
7287    ) -> Option<(SessionRecordId, SessionAddress)> {
7288        let bindings = self
7289            .session_bindings
7290            .lock()
7291            .unwrap_or_else(|poisoned| poisoned.into_inner());
7292        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7293        let record_id = binding.record_id.clone()?;
7294        Some((
7295            record_id,
7296            SessionAddress {
7297                workspace_id: binding.workspace_id.clone(),
7298                session: SessionKey { instance_id, generation },
7299            },
7300        ))
7301    }
7302
7303    fn mark_managed_recovery_required(
7304        &self,
7305        lease_id: &ManagedWorktreeLeaseId,
7306        failure_kind: ManagedWorktreeCleanupFailure,
7307    ) {
7308        let snapshot = {
7309            let mut registry = self.managed_worktrees.lock()
7310                .unwrap_or_else(|poisoned| poisoned.into_inner());
7311            let Some(lease) = registry.get_mut(lease_id) else { return };
7312            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7313            lease.cleanup_failure = Some(failure_kind);
7314            lease.updated_at_unix_ms = unix_time_ms();
7315            lease.snapshot()
7316        };
7317        let _ = self.persist_state();
7318        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7319    }
7320
7321    fn set_persistence_error(&self, error: Option<String>) {
7322        *self
7323            .persistence_error
7324            .write()
7325            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7326            .map(|message| session_registry::sanitized_persistence_summary(&message));
7327    }
7328
7329    fn persist_state(&self) -> io::Result<()> {
7330        let _transaction = self
7331            .state_transaction
7332            .lock()
7333            .unwrap_or_else(|poisoned| poisoned.into_inner());
7334        self.persist_state_locked()
7335    }
7336
7337    fn persist_state_locked(&self) -> io::Result<()> {
7338        let workspaces = self
7339            .workspaces
7340            .read()
7341            .unwrap_or_else(|poisoned| poisoned.into_inner())
7342            .clone();
7343        let records = self
7344            .session_records
7345            .lock()
7346            .unwrap_or_else(|poisoned| poisoned.into_inner())
7347            .records
7348            .values()
7349            .cloned()
7350            .collect::<Vec<_>>();
7351        let mut managed = self.managed_worktrees
7352            .lock()
7353            .unwrap_or_else(|poisoned| poisoned.into_inner());
7354        managed.reattach_record_holders(
7355            &managed_worktree_record_holders(&records),
7356            unix_time_ms(),
7357        );
7358        let materializations = self
7359            .materializations
7360            .lock()
7361            .unwrap_or_else(|poisoned| poisoned.into_inner())
7362            .values()
7363            .cloned()
7364            .collect::<Vec<_>>();
7365        let managed_spawn_replays = self.managed_spawn_replays
7366            .lock()
7367            .unwrap_or_else(|poisoned| poisoned.into_inner())
7368            .values()
7369            .cloned()
7370            .collect::<Vec<_>>();
7371        let result = session_registry::save_v10(
7372            self.state_path.as_deref(),
7373            &self.node_id,
7374            &workspaces,
7375            &records,
7376            &managed.records(),
7377            &managed.tombstones(),
7378            &materializations,
7379            &managed_spawn_replays,
7380        );
7381        match &result {
7382            Ok(warning) => self.set_persistence_error(warning.clone()),
7383            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7384        }
7385        result.map(|_| ())
7386    }
7387
7388    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7389        let _mutation_guard = self.mutation_gate.lock().await;
7390        self.begin_shutdown_locked().await
7391    }
7392
7393    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7394        if self.shutdown.swap(true, Ordering::AcqRel) {
7395            return Ok(());
7396        }
7397        SessionLifecycleHook::on_shutdown(self);
7398        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7399        let mut first_error = None;
7400        for session in &self.handle.snapshot().sessions {
7401            let remaining = deadline.saturating_duration_since(Instant::now());
7402            if remaining.is_zero() {
7403                first_error.get_or_insert_with(|| {
7404                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7405                });
7406                break;
7407            }
7408            if let Err(error) = self
7409                .dispatch_bounded(
7410                    ControlCommand::Stop {
7411                        instance_id: session.instance_id,
7412                        force: true,
7413                    },
7414                    remaining,
7415                )
7416                .await
7417            {
7418                first_error.get_or_insert(error);
7419            }
7420        }
7421        self.shutdown_notify.notify_waiters();
7422        match first_error {
7423            Some(error) => Err(error),
7424            None => Ok(()),
7425        }
7426    }
7427
7428    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7429        let bindings = self
7430            .session_bindings
7431            .lock()
7432            .unwrap_or_else(|poisoned| poisoned.into_inner());
7433        if bindings.len() >= CONTROL_SESSIONS_MAX {
7434            Err(failure(
7435                NodeFailureCode::BackendBusy,
7436                "node session-binding capacity is full; remove an existing session first",
7437            ))
7438        } else {
7439            Ok(())
7440        }
7441    }
7442
7443    /// One-owner workspace/env collision guard: a live session on
7444    /// `workspace_id` with a different `environment_profile` (option
7445    /// equality on the resolved receipt, including Some vs None) blocks
7446    /// another spawn on that same cwd. Same profile may still share the
7447    /// workspace; distinct profiles need distinct workspace ids (managed
7448    /// worktree).
7449    fn ensure_workspace_environment_profile_compatible(
7450        &self,
7451        workspace_id: &WorkspaceId,
7452        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7453    ) -> Result<(), NodeFailure> {
7454        let bindings = self
7455            .session_bindings
7456            .lock()
7457            .unwrap_or_else(|poisoned| poisoned.into_inner());
7458        for binding in bindings.values() {
7459            if &binding.workspace_id != workspace_id {
7460                continue;
7461            }
7462            if binding.environment_profile.as_ref() != environment_profile {
7463                return Err(failure(
7464                    NodeFailureCode::WorkspaceBusy,
7465                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7466                ));
7467            }
7468        }
7469        Ok(())
7470    }
7471
7472    #[cfg(test)]
7473    fn bind_session(
7474        &self,
7475        instance_id: AgentInstanceId,
7476        workspace_id: WorkspaceId,
7477        generation: SessionGeneration,
7478    ) {
7479        self.bind_session_with_policy(
7480            &SessionAddress {
7481                workspace_id,
7482                session: SessionKey { instance_id, generation },
7483            },
7484            ProviderRuntimePolicy::raw_pty(),
7485            None,
7486        );
7487    }
7488
7489    #[cfg(test)]
7490    fn bind_session_with_policy(
7491        &self,
7492        address: &SessionAddress,
7493        runtime_policy: ProviderRuntimePolicy,
7494        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7495    ) {
7496        self.bind_session_with_materialization(
7497            address,
7498            runtime_policy,
7499            environment_profile,
7500            None,
7501            None,
7502            None,
7503        );
7504    }
7505
7506    fn bind_session_with_materialization(
7507        &self,
7508        address: &SessionAddress,
7509        runtime_policy: ProviderRuntimePolicy,
7510        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7511        bundle: Option<ResolvedBundleReceipt>,
7512        context: Option<ResolvedContextPackReceipt>,
7513        materialization_id: Option<MaterializationId>,
7514    ) {
7515        let mut bindings = self
7516            .session_bindings
7517            .lock()
7518            .unwrap_or_else(|poisoned| poisoned.into_inner());
7519        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7520        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7521        bindings.insert(address.session.instance_id, SessionBinding {
7522            workspace_id: address.workspace_id.clone(),
7523            generation: address.session.generation,
7524            runtime_policy,
7525            pending_resume: None,
7526            record_id: None,
7527            managed_worktree_lease_id: None,
7528            environment_profile,
7529            bundle,
7530            context,
7531            materialization_id,
7532        });
7533    }
7534
7535    #[cfg(test)]
7536    fn bind_managed_session(
7537        &self,
7538        address: &SessionAddress,
7539        record_id: SessionRecordId,
7540        runtime_policy: ProviderRuntimePolicy,
7541        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7542    ) {
7543        self.bind_managed_session_with_materialization(
7544            address,
7545            record_id,
7546            runtime_policy,
7547            environment_profile,
7548            None,
7549            None,
7550            None,
7551        );
7552    }
7553
7554    fn bind_managed_session_with_materialization(
7555        &self,
7556        address: &SessionAddress,
7557        record_id: SessionRecordId,
7558        runtime_policy: ProviderRuntimePolicy,
7559        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7560        bundle: Option<ResolvedBundleReceipt>,
7561        context: Option<ResolvedContextPackReceipt>,
7562        materialization_id: Option<MaterializationId>,
7563    ) {
7564        let mut bindings = self
7565            .session_bindings
7566            .lock()
7567            .unwrap_or_else(|poisoned| poisoned.into_inner());
7568        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7569        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7570        bindings.insert(
7571            address.session.instance_id,
7572            SessionBinding {
7573                workspace_id: address.workspace_id.clone(),
7574                generation: address.session.generation,
7575                runtime_policy,
7576                pending_resume: None,
7577                record_id: Some(record_id),
7578                managed_worktree_lease_id: None,
7579                environment_profile,
7580                bundle,
7581                context,
7582                materialization_id,
7583            },
7584        );
7585    }
7586
7587    #[cfg(test)]
7588    fn bind_spawn_session(
7589        &self,
7590        address: &SessionAddress,
7591        provider: AgentId,
7592        mode: SessionMode,
7593        runtime_policy: ProviderRuntimePolicy,
7594        record_policy: SpawnRecordPolicy,
7595        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7596    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7597        self.bind_spawn_session_with_materialization(
7598            address,
7599            provider,
7600            mode,
7601            runtime_policy,
7602            record_policy,
7603            environment_profile,
7604            None,
7605            None,
7606            None,
7607        )
7608    }
7609
7610    fn bind_spawn_session_with_materialization(
7611        &self,
7612        address: &SessionAddress,
7613        provider: AgentId,
7614        mode: SessionMode,
7615        runtime_policy: ProviderRuntimePolicy,
7616        record_policy: SpawnRecordPolicy,
7617        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7618        bundle: Option<ResolvedBundleReceipt>,
7619        context: Option<ResolvedContextPackReceipt>,
7620        materialization_id: Option<MaterializationId>,
7621    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7622        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7623            && !runtime_policy.provider_session_identity
7624        {
7625            self.bind_session_with_materialization(
7626                address,
7627                runtime_policy,
7628                environment_profile,
7629                bundle,
7630                context,
7631                materialization_id,
7632            );
7633            return Ok(None);
7634        }
7635        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7636        let record = self.new_record(
7637            address,
7638            provider,
7639            mode,
7640            active_record_state(runtime_policy.provider_session_identity, false),
7641            None,
7642            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7643            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7644            record_owns_lifecycle.then(|| context.clone()).flatten(),
7645        )?;
7646        let record_id = record.record_id.clone();
7647        let transaction = self
7648            .state_transaction
7649            .lock()
7650            .unwrap_or_else(|poisoned| poisoned.into_inner());
7651        self.insert_record(record.clone())?;
7652        let previous_materialization = if record_owns_lifecycle {
7653            if let Some(materialization_id) = materialization_id.as_ref() {
7654                let mut materializations = self
7655                    .materializations
7656                    .lock()
7657                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7658                let Some(ownership) = materializations.get_mut(materialization_id) else {
7659                    drop(materializations);
7660                    self.remove_record_memory(&record_id);
7661                    return Err(failure(
7662                        NodeFailureCode::BackendOperationFailed,
7663                        "session environment ownership disappeared before binding",
7664                    ));
7665                };
7666                let previous = ownership.clone();
7667                if ownership
7668                    .transfer_to_record(record_id.clone(), unix_time_ms())
7669                    .is_err()
7670                {
7671                    drop(materializations);
7672                    self.remove_record_memory(&record_id);
7673                    return Err(failure(
7674                        NodeFailureCode::BackendOperationFailed,
7675                        "session environment ownership transfer failed",
7676                    ));
7677                }
7678                Some(previous)
7679            } else {
7680                None
7681            }
7682        } else {
7683            None
7684        };
7685        self.bind_managed_session_with_materialization(
7686            address,
7687            record_id.clone(),
7688            runtime_policy,
7689            environment_profile,
7690            bundle,
7691            context,
7692            materialization_id.clone(),
7693        );
7694        if let Err(error) = self.persist_state_locked() {
7695            self.remove_binding(address);
7696            self.remove_record_memory(&record_id);
7697            if let (Some(materialization_id), Some(previous)) =
7698                (materialization_id.as_ref(), previous_materialization)
7699            {
7700                self.materializations
7701                    .lock()
7702                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7703                    .insert(materialization_id.clone(), previous);
7704            }
7705            return Err(persistence_failure(error));
7706        }
7707        drop(transaction);
7708        self.publish_record(record);
7709        Ok(Some(record_id))
7710    }
7711
7712    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7713        for _ in 0..8 {
7714            let bytes = random_nonce().map_err(|error| {
7715                failure(
7716                    NodeFailureCode::BackendOperationFailed,
7717                    &format!("session record identity generation failed: {error}"),
7718                )
7719            })?;
7720            let mut value = String::with_capacity(27);
7721            value.push_str("sr-");
7722            for byte in &bytes[..12] {
7723                use std::fmt::Write as _;
7724                let _ = write!(&mut value, "{byte:02x}");
7725            }
7726            let record_id = SessionRecordId::new(value).map_err(|error| {
7727                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7728            })?;
7729            let records = self
7730                .session_records
7731                .lock()
7732                .unwrap_or_else(|poisoned| poisoned.into_inner());
7733            if !records.records.contains_key(&record_id) {
7734                return Ok(record_id);
7735            }
7736        }
7737        Err(failure(
7738            NodeFailureCode::BackendBusy,
7739            "could not allocate a unique session record ID",
7740        ))
7741    }
7742
7743    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7744        for _ in 0..8 {
7745            let bytes = random_nonce().map_err(|error| {
7746                failure(
7747                    NodeFailureCode::BackendOperationFailed,
7748                    &format!("task identity generation failed: {error}"),
7749                )
7750            })?;
7751            let mut nonce = [0_u8; 12];
7752            nonce.copy_from_slice(&bytes[..12]);
7753            let task_id = TaskId::from_nonce(nonce);
7754            let records = self
7755                .session_records
7756                .lock()
7757                .unwrap_or_else(|poisoned| poisoned.into_inner());
7758            if !records.records.values().any(|record| {
7759                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7760                    == Some(&task_id)
7761            }) {
7762                return Ok(task_id);
7763            }
7764        }
7765        Err(failure(
7766            NodeFailureCode::BackendBusy,
7767            "could not allocate a unique task ID",
7768        ))
7769    }
7770
7771    fn default_record_name(&self, provider: &AgentId) -> String {
7772        let records = self
7773            .session_records
7774            .lock()
7775            .unwrap_or_else(|poisoned| poisoned.into_inner());
7776        let ordinal = records
7777            .records
7778            .values()
7779            .filter(|record| &record.provider == provider)
7780            .count()
7781            .saturating_add(1);
7782        format!("{} #{ordinal}", provider.as_str())
7783    }
7784
7785    fn new_record(
7786        &self,
7787        address: &SessionAddress,
7788        provider: AgentId,
7789        mode: SessionMode,
7790        state: ManagedSessionState,
7791        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7792        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7793        bundle: Option<ResolvedBundleReceipt>,
7794        context: Option<ResolvedContextPackReceipt>,
7795    ) -> Result<ManagedSessionRecord, NodeFailure> {
7796        let canonical_root = self.workspace_root(&address.workspace_id)?;
7797        let now = unix_time_ms();
7798        Ok(ManagedSessionRecord {
7799            record_id: self.allocate_record_id()?,
7800            display_name: self.default_record_name(&provider),
7801            provider,
7802            mode,
7803            state,
7804            workspace_id: address.workspace_id.clone(),
7805            canonical_root: opaque_windows_path(canonical_root),
7806            provider_session,
7807            active_session: Some(address.clone()),
7808            environment_profile,
7809            bundle,
7810            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7811            context,
7812            exported_context: None,
7813            task_binding: None,
7814            created_at_unix_ms: now,
7815            updated_at_unix_ms: now,
7816            last_error: None,
7817        })
7818    }
7819
7820    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7821        let mut records = self
7822            .session_records
7823            .lock()
7824            .unwrap_or_else(|poisoned| poisoned.into_inner());
7825        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7826            return Err(failure(
7827                NodeFailureCode::BackendBusy,
7828                "managed session record capacity is full; forget an inactive record first",
7829            ));
7830        }
7831        if let Some(identity) = record.provider_session.as_ref() {
7832            if records.records.values().any(|candidate| {
7833                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7834                    session_registry::same_provider_session(
7835                        &candidate.provider,
7836                        candidate_identity,
7837                        &record.provider,
7838                        identity,
7839                    )
7840                })
7841            }) {
7842                return Err(failure(
7843                    NodeFailureCode::SessionRecordConflict,
7844                    "provider session is already represented by another managed record",
7845                ));
7846            }
7847        }
7848        match records.records.entry(record.record_id.clone()) {
7849            std::collections::btree_map::Entry::Vacant(entry) => {
7850                entry.insert(record);
7851            }
7852            std::collections::btree_map::Entry::Occupied(_) => {
7853                return Err(failure(
7854                    NodeFailureCode::SessionRecordConflict,
7855                    "session record ID collision",
7856                ));
7857            }
7858        }
7859        Ok(())
7860    }
7861
7862    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7863        self.session_records
7864            .lock()
7865            .unwrap_or_else(|poisoned| poisoned.into_inner())
7866            .records
7867            .get(record_id)
7868            .cloned()
7869            .ok_or_else(|| {
7870                failure(
7871                    NodeFailureCode::UnknownSessionRecord,
7872                    "managed session record does not exist",
7873                )
7874            })
7875    }
7876
7877    fn revalidate_session_record_preview(
7878        &self,
7879        expected: &ManagedSessionRecord,
7880        identity: &ProviderSessionIdentity,
7881    ) -> Result<(), NodeFailure> {
7882        let current = self.record(&expected.record_id)?;
7883        let current_root = self.workspace_root(&current.workspace_id)?;
7884        if current.provider != expected.provider
7885            || current.workspace_id != expected.workspace_id
7886            || current.canonical_root != expected.canonical_root
7887            || current.provider_session.as_ref() != Some(identity)
7888            || !platform::roots_equal(
7889                &current_root,
7890                windows_path_text(&current.canonical_root),
7891            )
7892        {
7893            return Err(failure(
7894                NodeFailureCode::SessionRecordConflict,
7895                "managed session changed while its preview was loading",
7896            ));
7897        }
7898        Ok(())
7899    }
7900
7901    fn revalidate_session_record_context_export(
7902        &self,
7903        expected: &ManagedSessionRecord,
7904        identity: Option<&ProviderSessionIdentity>,
7905        session: &SessionAddress,
7906        allow_clean_detachment: bool,
7907    ) -> Result<(), NodeFailure> {
7908        let current = self.record(&expected.record_id)?;
7909        let current_root = self.workspace_root(&current.workspace_id)?;
7910        let snapshot = self.internal_session_snapshot(session)?;
7911        let binding = self
7912            .session_bindings
7913            .lock()
7914            .unwrap_or_else(|poisoned| poisoned.into_inner())
7915            .get(&session.session.instance_id)
7916            .cloned()
7917            .ok_or_else(|| {
7918                failure(
7919                    NodeFailureCode::SessionRecordConflict,
7920                    "managed session runtime binding disappeared during context export",
7921                )
7922            })?;
7923        if !session_record_context_export_binding_is_exact(
7924            expected,
7925            &current,
7926            identity,
7927            session,
7928            &binding,
7929            &snapshot.agent_id,
7930            &current_root,
7931            allow_clean_detachment,
7932        ) || !session_record_context_export_source_is_usable(
7933            &current,
7934            &snapshot.status,
7935            allow_clean_detachment,
7936        ) {
7937            return Err(failure(
7938                NodeFailureCode::SessionRecordConflict,
7939                "managed session binding changed during context export",
7940            ));
7941        }
7942        Ok(())
7943    }
7944
7945    fn revalidate_loaded_history(
7946        &self,
7947        session: &SessionAddress,
7948        candidate_id: &str,
7949        loaded: &HistorySessionRecord,
7950    ) -> Result<(), NodeFailure> {
7951        let snapshot = self.internal_session_snapshot(session)?;
7952        if snapshot.history.pending.is_some()
7953            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7954            || snapshot.history.loaded.as_ref() != Some(loaded)
7955        {
7956            return Err(failure(
7957                NodeFailureCode::SessionRecordConflict,
7958                "managed session history changed during context export",
7959            ));
7960        }
7961        Ok(())
7962    }
7963
7964    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7965        self.session_records
7966            .lock()
7967            .unwrap_or_else(|poisoned| poisoned.into_inner())
7968            .records
7969            .remove(record_id)
7970    }
7971
7972    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7973        if self.destroy_record_materialization(record_id)? {
7974            self.publish(NodeEvent::SessionRecordRemoved {
7975                record_id: record_id.clone(),
7976            });
7977            return Ok(());
7978        }
7979        let transaction = self
7980            .state_transaction
7981            .lock()
7982            .unwrap_or_else(|poisoned| poisoned.into_inner());
7983        let Some(removed) = self.remove_record_memory(record_id) else {
7984            return Ok(());
7985        };
7986        if let Err(error) = self.persist_state_locked() {
7987            let _ = self.insert_record(removed);
7988            return Err(persistence_failure(error));
7989        }
7990        drop(transaction);
7991        self.publish(NodeEvent::SessionRecordRemoved {
7992            record_id: record_id.clone(),
7993        });
7994        Ok(())
7995    }
7996
7997    fn publish_record(&self, record: ManagedSessionRecord) {
7998        self.publish(NodeEvent::SessionRecordUpserted { record });
7999    }
8000
8001    fn mark_record_error(
8002        &self,
8003        record_id: &SessionRecordId,
8004        message: &str,
8005    ) -> Result<(), NodeFailure> {
8006        let transaction = self
8007            .state_transaction
8008            .lock()
8009            .unwrap_or_else(|poisoned| poisoned.into_inner());
8010        let previous = {
8011            let mut records = self
8012                .session_records
8013                .lock()
8014                .unwrap_or_else(|poisoned| poisoned.into_inner());
8015            let Some(record) = records.records.get_mut(record_id) else {
8016                return Ok(());
8017            };
8018            let previous = record.clone();
8019            record.state = ManagedSessionState::Unavailable;
8020            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8021            record.updated_at_unix_ms = unix_time_ms();
8022            previous
8023        };
8024        if let Err(error) = self.persist_state_locked() {
8025            self.session_records
8026                .lock()
8027                .unwrap_or_else(|poisoned| poisoned.into_inner())
8028                .records
8029                .insert(record_id.clone(), previous);
8030            return Err(persistence_failure(error));
8031        }
8032        let record = self.record(record_id)?;
8033        drop(transaction);
8034        self.publish_record(record);
8035        Ok(())
8036    }
8037
8038    fn rename_session_record(
8039        &self,
8040        record_id: &SessionRecordId,
8041        display_name: String,
8042    ) -> Result<ManagedSessionRecord, NodeFailure> {
8043        validate_display_name(&display_name)
8044            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8045        let _transaction = self
8046            .state_transaction
8047            .lock()
8048            .unwrap_or_else(|poisoned| poisoned.into_inner());
8049        let previous = {
8050            let mut records = self
8051                .session_records
8052                .lock()
8053                .unwrap_or_else(|poisoned| poisoned.into_inner());
8054            let record = records.records.get_mut(record_id).ok_or_else(|| {
8055                failure(
8056                    NodeFailureCode::UnknownSessionRecord,
8057                    "managed session record does not exist",
8058                )
8059            })?;
8060            let previous = record.clone();
8061            record.display_name = display_name;
8062            record.updated_at_unix_ms = unix_time_ms();
8063            previous
8064        };
8065        if let Err(error) = self.persist_state_locked() {
8066            self.session_records
8067                .lock()
8068                .unwrap_or_else(|poisoned| poisoned.into_inner())
8069                .records
8070                .insert(record_id.clone(), previous);
8071            return Err(persistence_failure(error));
8072        }
8073        let updated = self.record(record_id)?;
8074        self.publish_record(updated.clone());
8075        Ok(updated)
8076    }
8077
8078    fn set_session_task(
8079        &self,
8080        record_id: &SessionRecordId,
8081        expected_revision: u64,
8082        target: SessionTaskTargetV1,
8083    ) -> Result<ManagedSessionRecord, NodeFailure> {
8084        let transaction = self
8085            .state_transaction
8086            .lock()
8087            .unwrap_or_else(|poisoned| poisoned.into_inner());
8088        let current = self.record(record_id)?;
8089        let current_task_id = current
8090            .task_binding
8091            .as_ref()
8092            .and_then(|binding| binding.task_id.as_ref());
8093        let current_revision = current
8094            .task_binding
8095            .as_ref()
8096            .map_or(0, |binding| binding.revision);
8097        if expected_revision != current_revision {
8098            return Err(failure(
8099                NodeFailureCode::SessionRecordConflict,
8100                "managed session task binding revision changed",
8101            ));
8102        }
8103        let exact = match &target {
8104            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8105            SessionTaskTargetV1::Clear => current_task_id.is_none(),
8106            SessionTaskTargetV1::New => false,
8107        };
8108        if exact {
8109            return Ok(current);
8110        }
8111        let revision = current_revision.checked_add(1).ok_or_else(|| {
8112            failure(
8113                NodeFailureCode::SessionRecordConflict,
8114                "managed session task binding revision is exhausted",
8115            )
8116        })?;
8117        let task_id = match target {
8118            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8119            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8120            SessionTaskTargetV1::Clear => None,
8121        };
8122        let changed_at_unix_ms = unix_time_ms()
8123            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8124                failure(
8125                    NodeFailureCode::SessionRecordConflict,
8126                    "managed session record timestamp is exhausted",
8127                )
8128            })?);
8129        let updated = {
8130            let mut records = self
8131                .session_records
8132                .lock()
8133                .unwrap_or_else(|poisoned| poisoned.into_inner());
8134            let record = records.records.get_mut(record_id).ok_or_else(|| {
8135                failure(
8136                    NodeFailureCode::UnknownSessionRecord,
8137                    "managed session record does not exist",
8138                )
8139            })?;
8140            record.task_binding = Some(SessionTaskBindingV1 {
8141                revision,
8142                task_id,
8143                changed_at_unix_ms,
8144            });
8145            record.updated_at_unix_ms = changed_at_unix_ms;
8146            record.clone()
8147        };
8148        if let Err(error) = self.persist_state_locked() {
8149            self.session_records
8150                .lock()
8151                .unwrap_or_else(|poisoned| poisoned.into_inner())
8152                .records
8153                .insert(record_id.clone(), current);
8154            return Err(persistence_failure(error));
8155        }
8156        drop(transaction);
8157        self.publish_record(updated.clone());
8158        Ok(updated)
8159    }
8160
8161    fn index_provider_session(
8162        &self,
8163        workspace_id: WorkspaceId,
8164        provider: AgentId,
8165        identity: ProviderSessionIdentity,
8166        display_name: String,
8167    ) -> Result<ManagedSessionRecord, NodeFailure> {
8168        self.index_provider_session_with_policy(
8169            workspace_id,
8170            provider,
8171            identity,
8172            display_name,
8173            false,
8174        )
8175    }
8176
8177    fn index_provider_session_with_policy(
8178        &self,
8179        workspace_id: WorkspaceId,
8180        provider: AgentId,
8181        identity: ProviderSessionIdentity,
8182        display_name: String,
8183        allow_validated_transcript_path: bool,
8184    ) -> Result<ManagedSessionRecord, NodeFailure> {
8185        validate_display_name(&display_name)
8186            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8187        identity
8188            .validate()
8189            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8190        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8191            return Err(failure(
8192                NodeFailureCode::InvalidRequest,
8193                "provider session reference index v1 does not accept transcript paths",
8194            ));
8195        }
8196        let canonical_root = self.workspace_root(&workspace_id)?;
8197        if !self.enabled_providers.contains(&provider) {
8198            return Err(failure(
8199                NodeFailureCode::InvalidRequest,
8200                "provider is not enabled on this node",
8201            ));
8202        }
8203
8204        let transaction = self
8205            .state_transaction
8206            .lock()
8207            .unwrap_or_else(|poisoned| poisoned.into_inner());
8208        {
8209            let records = self
8210                .session_records
8211                .lock()
8212                .unwrap_or_else(|poisoned| poisoned.into_inner());
8213            if let Some(existing) = records.records.values().find(|candidate| {
8214                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8215                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
8216                })
8217            }) {
8218                if existing.provider == provider && existing.workspace_id == workspace_id {
8219                    return Ok(existing.clone());
8220                }
8221                return Err(failure(
8222                    NodeFailureCode::SessionRecordConflict,
8223                    "provider session reference conflicts with another provider or workspace",
8224                ));
8225            }
8226            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8227                return Err(failure(
8228                    NodeFailureCode::BackendBusy,
8229                    "managed session record capacity is full; forget an inactive record first",
8230                ));
8231            }
8232        }
8233
8234        let now = unix_time_ms();
8235        let record = ManagedSessionRecord {
8236            record_id: self.allocate_record_id()?,
8237            display_name,
8238            provider,
8239            mode: SessionMode::Pty,
8240            state: ManagedSessionState::Dormant,
8241            workspace_id,
8242            canonical_root: opaque_windows_path(canonical_root),
8243            provider_session: Some(identity),
8244            active_session: None,
8245            environment_profile: None,
8246            bundle: None,
8247            context_id: None,
8248            context: None,
8249            exported_context: None,
8250            task_binding: None,
8251            created_at_unix_ms: now,
8252            updated_at_unix_ms: now,
8253            last_error: None,
8254        };
8255        self.insert_record(record.clone())?;
8256        if let Err(error) = self.persist_state_locked() {
8257            self.remove_record_memory(&record.record_id);
8258            return Err(persistence_failure(error));
8259        }
8260        drop(transaction);
8261        self.publish_record(record.clone());
8262        Ok(record)
8263    }
8264
8265    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8266        self.session_bindings
8267            .lock()
8268            .unwrap_or_else(|poisoned| poisoned.into_inner())
8269            .iter()
8270            .find_map(|(instance_id, binding)| {
8271                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8272                    workspace_id: binding.workspace_id.clone(),
8273                    session: SessionKey {
8274                        instance_id: *instance_id,
8275                        generation: binding.generation,
8276                    },
8277                })
8278            })
8279    }
8280
8281    async fn forget_session_record(
8282        &self,
8283        record_id: &SessionRecordId,
8284    ) -> Result<(), NodeFailure> {
8285        let record = self.record(record_id)?;
8286        if record.active_session.is_some()
8287            || matches!(
8288                record.state,
8289                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8290            )
8291        {
8292            return Err(failure(
8293                NodeFailureCode::SessionRecordBusy,
8294                "stop the managed session before forgetting it",
8295            ));
8296        }
8297        if let Some(address) = self.bound_address_for_record(record_id) {
8298            self.remove_session(&address).await?;
8299        }
8300        self.remove_managed_session_record(record_id, &record.workspace_id).await
8301    }
8302
8303    /// Removes one managed session record from durable state and notifies
8304    /// every listener -- the shared transaction body behind both
8305    /// `forget_session_record`'s operator-driven path (busy-checked above
8306    /// it, and clears any still-live session binding before calling in
8307    /// here) and the retention sweep (`retire_unavailable_session_records`,
8308    /// which only ever selects `ManagedSessionState::Unavailable` records
8309    /// that are already known never-busy and never bound, so it calls
8310    /// straight into this with neither check). Cleans a managed-worktree
8311    /// lease if the record held one, removes the record's environment
8312    /// materialization (or, absent one, removes it from memory directly),
8313    /// persists under `state_transaction` with rollback on persist failure,
8314    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8315    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8316    /// exactly as it does for an operator-driven forget.
8317    async fn remove_managed_session_record(
8318        &self,
8319        record_id: &SessionRecordId,
8320        workspace_id: &WorkspaceId,
8321    ) -> Result<(), NodeFailure> {
8322        let managed_lease_id = self.managed_worktrees.lock()
8323            .unwrap_or_else(|poisoned| poisoned.into_inner())
8324            .lease_for_workspace(workspace_id);
8325        if !self.destroy_record_materialization(record_id)? {
8326            let transaction = self
8327                .state_transaction
8328                .lock()
8329                .unwrap_or_else(|poisoned| poisoned.into_inner());
8330            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8331                failure(
8332                    NodeFailureCode::UnknownSessionRecord,
8333                    "managed session record does not exist",
8334                )
8335            })?;
8336            if let Err(error) = self.persist_state_locked() {
8337                let _ = self.insert_record(removed);
8338                return Err(persistence_failure(error));
8339            }
8340            drop(transaction);
8341        }
8342        self.publish(NodeEvent::SessionRecordRemoved {
8343            record_id: record_id.clone(),
8344        });
8345        if let Some(lease_id) = managed_lease_id {
8346            if let Some(snapshot) = self.managed_worktrees.lock()
8347                .unwrap_or_else(|poisoned| poisoned.into_inner())
8348                .get(&lease_id)
8349                .map(ManagedWorktreeLeaseRecord::snapshot)
8350            {
8351                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8352            }
8353            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8354        }
8355        Ok(())
8356    }
8357
8358    /// Retires `ManagedSessionState::Unavailable` records against the
8359    /// node's configured `session_record_retention` age/keep-N policy.
8360    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8361    /// `Dormant` is resumable and folding it into this sweep would delete
8362    /// records a caller can still act on. Both policy fields default to
8363    /// `0` (disabled); when both are `0` this is a no-op. When both are
8364    /// set, a record is retired only if it fails BOTH tests (older than
8365    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8366    /// workspace); when only one is set, that one alone decides. Called
8367    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8368    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8369    /// loop -- see both call sites.
8370    async fn retire_unavailable_session_records(&self) {
8371        let policy = self.session_record_retention;
8372        let age_enabled = policy.age_ms > 0;
8373        let keep_enabled = policy.keep_per_workspace > 0;
8374        if !age_enabled && !keep_enabled {
8375            return;
8376        }
8377        let now = unix_time_ms();
8378        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8379            self.session_records
8380                .lock()
8381                .unwrap_or_else(|poisoned| poisoned.into_inner())
8382                .records
8383                .values()
8384                .filter(|record| record.state == ManagedSessionState::Unavailable)
8385                .map(|record| {
8386                    (
8387                        record.record_id.clone(),
8388                        record.workspace_id.clone(),
8389                        record.provider.clone(),
8390                        record.updated_at_unix_ms,
8391                    )
8392                })
8393                .collect()
8394        };
8395        if candidates.is_empty() {
8396            return;
8397        }
8398        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8399            BTreeMap::new();
8400        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8401            by_workspace
8402                .entry(workspace_id)
8403                .or_default()
8404                .push((record_id, provider, updated_at_unix_ms));
8405        }
8406        let reason = if age_enabled && keep_enabled {
8407            "age+keep-n"
8408        } else if age_enabled {
8409            "age"
8410        } else {
8411            "keep-n"
8412        };
8413        let mut retired: u64 = 0;
8414        for (workspace_id, mut records) in by_workspace {
8415            // Newest `updated_at_unix_ms` first, so index `>=
8416            // keep_per_workspace` names exactly "beyond the newest N".
8417            records.sort_by(|left, right| right.2.cmp(&left.2));
8418            for (index, (record_id, provider, updated_at_unix_ms)) in
8419                records.into_iter().enumerate()
8420            {
8421                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8422                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8423                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8424                let should_retire = if age_enabled && keep_enabled {
8425                    age_hit && keep_hit
8426                } else {
8427                    age_hit || keep_hit
8428                };
8429                if !should_retire {
8430                    continue;
8431                }
8432                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8433                    Ok(()) => {
8434                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8435                        retired += 1;
8436                        tracing::info!(
8437                            node_id = %self.node_id,
8438                            record_id = %record_id,
8439                            workspace_id = %workspace_id,
8440                            provider = %provider,
8441                            age_ms = record_age_ms,
8442                            reason,
8443                            "managed session record retired"
8444                        );
8445                    }
8446                    Err(error) => tracing::warn!(
8447                        node_id = %self.node_id,
8448                        record_id = %record_id,
8449                        workspace_id = %workspace_id,
8450                        code = ?error.code,
8451                        cause = %error.message,
8452                        "session record retention sweep failed to retire a record"
8453                    ),
8454                }
8455            }
8456        }
8457        if retired > 0 {
8458            let remaining_unavailable = self
8459                .session_records
8460                .lock()
8461                .unwrap_or_else(|poisoned| poisoned.into_inner())
8462                .records
8463                .values()
8464                .filter(|record| record.state == ManagedSessionState::Unavailable)
8465                .count();
8466            tracing::info!(
8467                node_id = %self.node_id,
8468                retired,
8469                remaining_unavailable,
8470                "session record retention sweep completed"
8471            );
8472        }
8473    }
8474
8475    async fn resume_session_record(
8476        &self,
8477        record_id: &SessionRecordId,
8478        terminal_size: gate4agent_types::TerminalSize,
8479        initial_prompt: Option<String>,
8480    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8481        let record = self.record(record_id)?;
8482        if record.active_session.is_some()
8483            || matches!(
8484                record.state,
8485                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8486            )
8487        {
8488            return Err(failure(
8489                NodeFailureCode::SessionRecordBusy,
8490                "managed session already has a live runtime binding",
8491            ));
8492        }
8493        let identity = record.provider_session.clone().ok_or_else(|| {
8494            failure(
8495                NodeFailureCode::SessionRecordNotResumable,
8496                "managed session has no verified provider session identity",
8497            )
8498        })?;
8499        let working_directory = self.workspace_root(&record.workspace_id)?;
8500        if !platform::roots_equal(
8501            &working_directory,
8502            windows_path_text(&record.canonical_root),
8503        ) {
8504            return Err(failure(
8505                NodeFailureCode::SessionRecordConflict,
8506                "managed session workspace root changed; refusing to resume in another directory",
8507            ));
8508        }
8509        {
8510            let records = self
8511                .session_records
8512                .lock()
8513                .unwrap_or_else(|poisoned| poisoned.into_inner());
8514            if records.records.values().any(|candidate| {
8515                candidate.record_id != *record_id
8516                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8517                        session_registry::same_provider_session(
8518                            &candidate.provider,
8519                            candidate_identity,
8520                            &record.provider,
8521                            &identity,
8522                        )
8523                    })
8524                    && (candidate.active_session.is_some()
8525                        || matches!(
8526                            candidate.state,
8527                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8528                        ))
8529            }) {
8530                return Err(failure(
8531                    NodeFailureCode::SessionRecordConflict,
8532                    "provider session is already active through another managed record",
8533                ));
8534            }
8535        }
8536        let runtime_requirement = if initial_prompt.is_some() {
8537            ProviderRuntimeRequirement::ResumeWithPrompt
8538        } else {
8539            ProviderRuntimeRequirement::Resume
8540        };
8541        let runtime_policy = self.admit_provider_runtime(
8542            &record.provider,
8543            runtime_requirement,
8544        )
8545        .await?;
8546        let request = ResumeLaunchRequest {
8547            working_directory,
8548            terminal_size,
8549            initial_prompt,
8550        };
8551        request
8552            .validate()
8553            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8554        self.ensure_binding_capacity()?;
8555        let resolved_materialization = match self.resolve_record_materialization(
8556            record_id,
8557            &record.provider,
8558            record.mode,
8559            record.environment_profile.as_ref(),
8560            record.bundle.as_ref(),
8561            record.context.as_ref(),
8562        ) {
8563            Ok(materialization) => materialization,
8564            Err(error) => {
8565                let label = match error.code {
8566                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8567                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8568                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8569                    _ => "environment-profile-unavailable",
8570                };
8571                self.mark_record_error(record_id, label)?;
8572                return Err(error);
8573            }
8574        };
8575        let (materialization_id, environment_overlay) = match resolved_materialization {
8576            Some((id, overlay)) => (Some(id), overlay),
8577            None => (None, None),
8578        };
8579        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8580        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8581            instance_id,
8582            &record.provider,
8583            record.mode,
8584            record.environment_profile.as_ref(),
8585        ) {
8586            Ok(selection) => selection,
8587            Err(error) => {
8588                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8589                return Err(error);
8590            }
8591        };
8592        let mut instance_overlay = environment_overlay
8593            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8594            .transpose()?
8595            .flatten();
8596        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8597            self.remove_session(&stale_address).await?;
8598        }
8599        let address = SessionAddress {
8600            workspace_id: record.workspace_id.clone(),
8601            session: SessionKey {
8602                instance_id,
8603                generation: SessionGeneration(1),
8604            },
8605        };
8606        let transaction = self
8607            .state_transaction
8608            .lock()
8609            .unwrap_or_else(|poisoned| poisoned.into_inner());
8610        let current = self.record(record_id)?;
8611        if current.active_session.is_some()
8612            || matches!(
8613                current.state,
8614                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8615            )
8616        {
8617            return Err(failure(
8618                NodeFailureCode::SessionRecordBusy,
8619                "managed session became active while resume was being prepared",
8620            ));
8621        }
8622        self.bind_managed_session_with_materialization(
8623            &address,
8624            record_id.clone(),
8625            runtime_policy,
8626            record.environment_profile.clone(),
8627            record.bundle.clone(),
8628            record.context.clone(),
8629            materialization_id,
8630        );
8631        if let Some(selection) = environment_selection.take() {
8632            selection.retain();
8633        }
8634        if let Some(overlay) = instance_overlay.take() {
8635            overlay.retain();
8636        }
8637        drop(transaction);
8638
8639        let transport = match record.mode {
8640            SessionMode::Pty => TransportKind::Pty,
8641            SessionMode::Inline => TransportKind::Pipe,
8642            SessionMode::Acp => TransportKind::Acp,
8643        };
8644        let agent_id = record.provider.clone();
8645        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8646        if let Err(error) = self
8647            .dispatch_bounded(
8648                ControlCommand::Register {
8649                    instance_id,
8650                    agent_id,
8651                    transport,
8652                },
8653                dispatch_timeout,
8654            )
8655            .await
8656        {
8657            if let Some(binding) = self.remove_binding(&address) {
8658                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8659            }
8660            return Err(error);
8661        }
8662        let command = self.prepare_command(ControlCommand::Resume {
8663            instance_id,
8664            target: ResumeTarget::ProviderSession { identity },
8665            runtime_policy,
8666            request,
8667        });
8668        let started_after = self.current_sequence();
8669        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8670            self.rollback_spawn(&address, dispatch_timeout).await?;
8671            return Err(error);
8672        }
8673        if let Err(error) = self.dispatch_envelope(command) {
8674            self.clear_armed_resume(&address);
8675            self.rollback_spawn(&address, dispatch_timeout).await?;
8676            return Err(error);
8677        }
8678        match self
8679            .wait_for_record_resume(&address, started_after)
8680            .await
8681        {
8682            Ok(settled_address) => {
8683                let managed_lease_id = {
8684                    self.managed_worktrees.lock()
8685                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8686                        .lease_for_workspace(&record.workspace_id)
8687                };
8688                if let Some(lease_id) = managed_lease_id {
8689                    {
8690                        let mut bindings = self.session_bindings.lock()
8691                            .unwrap_or_else(|poisoned| poisoned.into_inner());
8692                        let binding = bindings.get_mut(&settled_address.session.instance_id)
8693                            .ok_or_else(|| failure(
8694                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8695                                "resumed managed session lost its exact binding",
8696                            ))?;
8697                        if binding.generation != settled_address.session.generation {
8698                            return Err(failure(
8699                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8700                                "resumed managed session generation diverged",
8701                            ));
8702                        }
8703                        binding.managed_worktree_lease_id = Some(lease_id.clone());
8704                    }
8705                    let snapshot = self.managed_worktrees.lock()
8706                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8707                        .bind_session(
8708                            &lease_id,
8709                            ManagedWorktreeSessionHolder {
8710                                incarnation_id: self.incarnation_id,
8711                                instance_id: settled_address.session.instance_id,
8712                                generation: settled_address.session.generation,
8713                            },
8714                            Some(record_id.clone()),
8715                            unix_time_ms(),
8716                        ).map_err(|message| failure(
8717                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
8718                            &message,
8719                        ))?;
8720                    if let Err(error) = self.persist_state() {
8721                        let _ = self.remove_session(&settled_address).await;
8722                        return Err(persistence_failure(error));
8723                    }
8724                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8725                }
8726                Ok((self.record(record_id)?, settled_address))
8727            }
8728            Err(error) => {
8729                if matches!(
8730                    error.code,
8731                    NodeFailureCode::SessionRecordNotResumable
8732                        | NodeFailureCode::BackendOperationFailed
8733                ) {
8734                    if let Some(bound) = self.bound_address_for_record(record_id) {
8735                        let _ = self.remove_session(&bound).await;
8736                    }
8737                }
8738                Err(error)
8739            }
8740        }
8741    }
8742
8743    async fn wait_for_record_resume(
8744        &self,
8745        initial_address: &SessionAddress,
8746        after_sequence: u64,
8747    ) -> Result<SessionAddress, NodeFailure> {
8748        let deadline = Instant::now()
8749            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8750        let mut scan_after = after_sequence;
8751        loop {
8752            let events = self
8753                .history
8754                .lock()
8755                .unwrap_or_else(|poisoned| poisoned.into_inner())
8756                .events
8757                .iter()
8758                .filter(|event| event.sequence > scan_after)
8759                .cloned()
8760                .collect::<Vec<_>>();
8761            for envelope in events {
8762                scan_after = scan_after.max(envelope.sequence);
8763                let NodeEvent::Control { address, event } = envelope.event else {
8764                    continue;
8765                };
8766                if address.session.instance_id != initial_address.session.instance_id {
8767                    continue;
8768                }
8769                match event.event {
8770                    ControlEventKind::Resumed { .. } => return Ok(address),
8771                    ControlEventKind::ResumeDenied { reason } => {
8772                        return Err(failure(
8773                            NodeFailureCode::SessionRecordNotResumable,
8774                            &reason,
8775                        ));
8776                    }
8777                    ControlEventKind::ResumeFailed { message }
8778                    | ControlEventKind::CommandRejected { message } => {
8779                        return Err(failure(
8780                            NodeFailureCode::BackendOperationFailed,
8781                            &message,
8782                        ));
8783                    }
8784                    _ => {}
8785                }
8786            }
8787            if Instant::now() >= deadline {
8788                return Err(failure(
8789                    NodeFailureCode::BackendBusy,
8790                    "managed resume did not settle before the bounded deadline",
8791                ));
8792            }
8793            sleep(Duration::from_millis(5)).await;
8794        }
8795    }
8796
8797    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8798        self.workspaces
8799            .read()
8800            .unwrap_or_else(|poisoned| poisoned.into_inner())
8801            .get(workspace_id)
8802            .cloned()
8803            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8804    }
8805
8806    fn workspace_roots(&self) -> Vec<PathBuf> {
8807        self.workspaces
8808            .read()
8809            .unwrap_or_else(|poisoned| poisoned.into_inner())
8810            .values()
8811            .map(PathBuf::from)
8812            .collect()
8813    }
8814
8815    async fn inspect_workspace(
8816        &self,
8817        workspace_id: WorkspaceId,
8818    ) -> Result<WorkspaceInspection, NodeFailure> {
8819        let _permit = self
8820            .inspection_slots
8821            .clone()
8822            .try_acquire_owned()
8823            .map_err(|_| {
8824                tracing::warn!(
8825                    workspace_id = %workspace_id,
8826                    "workspace inspection rejected: inspection capacity is busy",
8827                );
8828                failure(
8829                    NodeFailureCode::BackendBusy,
8830                    "workspace inspection capacity is busy",
8831                )
8832            })?;
8833        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8834            tracing::warn!(
8835                workspace_id = %workspace_id,
8836                code = ?error.code,
8837                "workspace inspection rejected: workspace is not registered",
8838            );
8839            error
8840        })?;
8841        let tree_root = canonical_root.clone();
8842        let started_at = Instant::now();
8843        let time_budget_ms = workspace_inspection_time_budget_ms();
8844        let entry_cap = workspace_inspection_entry_cap();
8845        let deadline = started_at + Duration::from_millis(time_budget_ms);
8846        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8847            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8848        })
8849        .await
8850        .map_err(|error| {
8851            tracing::warn!(
8852                workspace_id = %workspace_id,
8853                error = %error,
8854                "workspace inspection rejected: walk task failed",
8855            );
8856            failure(
8857                NodeFailureCode::BackendOperationFailed,
8858                &format!("workspace inspection task failed: {error}"),
8859            )
8860        })?;
8861        let (mut git, git_time_budget_exceeded) =
8862            inspect_git_workspace(&canonical_root, deadline).await;
8863        if !git.worktrees.is_empty() {
8864            let registered = self
8865                .workspaces
8866                .read()
8867                .unwrap_or_else(|poisoned| poisoned.into_inner())
8868                .iter()
8869                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8870                .collect::<Vec<_>>();
8871            for worktree in &mut git.worktrees {
8872                worktree.workspace_id = registered
8873                    .iter()
8874                    .find(|(_, root)| {
8875                        worktree_paths_equal(root, windows_path_text(&worktree.path))
8876                    })
8877                    .map(|(workspace_id, _)| workspace_id.clone());
8878            }
8879        }
8880        if git.is_repository {
8881            git.managed_worktree = self
8882                .managed_worktrees
8883                .lock()
8884                .unwrap_or_else(|poisoned| poisoned.into_inner())
8885                .git_scope(&workspace_id, git.branch.as_deref());
8886        }
8887        let truncation = workspace_inspection_truncation(
8888            &walk_budget,
8889            git_time_budget_exceeded,
8890            started_at.elapsed(),
8891        );
8892        if let Some(truncation) = &truncation {
8893            tracing::info!(
8894                workspace_id = %workspace_id,
8895                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8896                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8897                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8898                entries_visited = truncation.entries_visited,
8899                elapsed_ms = truncation.elapsed_ms,
8900                "workspace inspection truncated by its inner budget",
8901            );
8902        }
8903        Ok(WorkspaceInspection {
8904            workspace_id,
8905            entries,
8906            tree_truncated,
8907            git,
8908            truncation,
8909        })
8910    }
8911
8912    async fn browse_host_directories(
8913        &self,
8914        directory: Option<OpaqueHostPath>,
8915        after: Option<OpaqueHostPath>,
8916    ) -> Result<HostDirectoryListing, NodeFailure> {
8917        let permit = self
8918            .inspection_slots
8919            .clone()
8920            .try_acquire_owned()
8921            .map_err(|_| {
8922                failure(
8923                    NodeFailureCode::BackendBusy,
8924                    "host directory browse capacity is busy",
8925                )
8926            })?;
8927        let task = tokio::task::spawn_blocking(move || {
8928            let _permit = permit;
8929            browse_host_directories(directory, after)
8930        });
8931        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8932            .await
8933            .map_err(|_| {
8934                failure(
8935                    NodeFailureCode::HostDirectoryReadTimedOut,
8936                    "host directory browse exceeded its bounded deadline",
8937                )
8938            })?
8939            .map_err(|_| {
8940                failure(
8941                    NodeFailureCode::HostDirectoryReadFailed,
8942                    "host directory browse task failed",
8943                )
8944            })?
8945            .map_err(host_directory_failure)
8946    }
8947
8948    async fn read_workspace_file(
8949        &self,
8950        workspace_id: WorkspaceId,
8951        path: RepositoryPath,
8952    ) -> Result<WorkspaceFileRead, NodeFailure> {
8953        let canonical_root = self.workspace_root(&workspace_id)?;
8954        let permit = self
8955            .inspection_slots
8956            .clone()
8957            .try_acquire_owned()
8958            .map_err(|_| {
8959                failure(
8960                    NodeFailureCode::BackendBusy,
8961                    "workspace file read capacity is busy",
8962                )
8963            })?;
8964        let path_to_read = path.clone();
8965        let task = tokio::task::spawn_blocking(move || {
8966            let _permit = permit;
8967            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8968        });
8969        let content = timeout(
8970            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8971            task,
8972        )
8973        .await
8974        .map_err(|_| {
8975            failure(
8976                NodeFailureCode::RepositoryFileReadTimedOut,
8977                "repository file read exceeded its bounded deadline",
8978            )
8979        })?
8980        .map_err(|_| {
8981            failure(
8982                NodeFailureCode::RepositoryFileReadFailed,
8983                "repository file read task failed",
8984            )
8985        })?
8986        .map_err(workspace_file_failure)?;
8987        let content = match content {
8988            WorkspaceFileBytes::Utf8(text) => {
8989                let revision = Some(workspace_file_revision(text.as_bytes()));
8990                return Ok(WorkspaceFileRead {
8991                    workspace_id,
8992                    path,
8993                    content: WorkspaceFileContent::Utf8 {
8994                        byte_len: u32::try_from(text.len())
8995                            .expect("bounded workspace text length must fit u32"),
8996                        text,
8997                    },
8998                    revision,
8999                });
9000            }
9001            WorkspaceFileBytes::NonUtf8 { byte_length } => {
9002                WorkspaceFileContent::NonUtf8 {
9003                    byte_len: u32::try_from(byte_length)
9004                        .expect("bounded workspace file length must fit u32"),
9005                }
9006            }
9007            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9008                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9009            },
9010        };
9011        Ok(WorkspaceFileRead {
9012            workspace_id,
9013            path,
9014            content,
9015            revision: None,
9016        })
9017    }
9018
9019    async fn write_workspace_file(
9020        &self,
9021        workspace_id: WorkspaceId,
9022        path: RepositoryPath,
9023        expected_revision: WorkspaceFileRevision,
9024        text: String,
9025    ) -> Result<WorkspaceFileRead, NodeFailure> {
9026        let canonical_root = self.workspace_root(&workspace_id)?;
9027        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9028            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9029        })?;
9030        let path_to_write = path.clone();
9031        let expected = expected_revision.as_str().to_owned();
9032        let text_to_write = text.clone();
9033        let task = tokio::task::spawn_blocking(move || {
9034            let _permit = permit;
9035            write_workspace_file_to_disk(
9036                Path::new(&canonical_root),
9037                &path_to_write,
9038                &expected,
9039                &text_to_write,
9040            )
9041        });
9042        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9043            .await
9044            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9045            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9046            .map_err(workspace_file_write_failure)?;
9047        Ok(WorkspaceFileRead {
9048            workspace_id,
9049            path,
9050            content: WorkspaceFileContent::Utf8 {
9051                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9052                text,
9053            },
9054            revision: Some(WorkspaceFileRevision::new(revision)
9055                .expect("workspace writer returns a valid SHA-256 revision")),
9056        })
9057    }
9058
9059    async fn create_workspace_file(
9060        &self,
9061        workspace_id: WorkspaceId,
9062        path: RepositoryPath,
9063    ) -> Result<WorkspaceFileRead, NodeFailure> {
9064        let canonical_root = self.workspace_root(&workspace_id)?;
9065        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9066            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9067        })?;
9068        let path_to_create = path.clone();
9069        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9070        let task_commit_state = Arc::clone(&commit_state);
9071        let task = tokio::task::spawn_blocking(move || {
9072            let _permit = permit;
9073            create_workspace_file_on_disk(
9074                Path::new(&canonical_root),
9075                &path_to_create,
9076                &task_commit_state,
9077            )
9078        });
9079        let revision = settle_workspace_entry_create(
9080            task,
9081            commit_state,
9082            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9083            "repository file create exceeded its bounded deadline",
9084            "repository file create task failed",
9085        )
9086        .await?;
9087        Ok(WorkspaceFileRead {
9088            workspace_id,
9089            path,
9090            content: WorkspaceFileContent::Utf8 {
9091                text: String::new(),
9092                byte_len: 0,
9093            },
9094            revision: Some(WorkspaceFileRevision::new(revision)
9095                .expect("workspace creator returns a valid SHA-256 revision")),
9096        })
9097    }
9098
9099    async fn create_workspace_directory(
9100        &self,
9101        workspace_id: WorkspaceId,
9102        path: RepositoryPath,
9103    ) -> Result<WorkspaceEntry, NodeFailure> {
9104        let canonical_root = self.workspace_root(&workspace_id)?;
9105        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9106            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9107        })?;
9108        let path_to_create = path.clone();
9109        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9110        let task_commit_state = Arc::clone(&commit_state);
9111        let task = tokio::task::spawn_blocking(move || {
9112            let _permit = permit;
9113            create_workspace_directory_on_disk(
9114                Path::new(&canonical_root),
9115                &path_to_create,
9116                &task_commit_state,
9117            )
9118        });
9119        settle_workspace_entry_create(
9120            task,
9121            commit_state,
9122            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9123            "repository directory create exceeded its bounded deadline",
9124            "repository directory create task failed",
9125        )
9126        .await?;
9127        Ok(WorkspaceEntry {
9128            relative_path: path,
9129            kind: WorkspaceEntryKind::Directory,
9130        })
9131    }
9132
9133    async fn read_git_history(
9134        &self,
9135        workspace_id: WorkspaceId,
9136        path: Option<RepositoryPath>,
9137        before: Option<GitObjectId>,
9138        limit: u16,
9139    ) -> Result<GitHistoryPage, NodeFailure> {
9140        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9141            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9142        })?;
9143        let root = self.workspace_root(&workspace_id)?;
9144        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9145    }
9146
9147    async fn read_git_diff(
9148        &self,
9149        workspace_id: WorkspaceId,
9150        request: GitDiffRequest,
9151    ) -> Result<GitDiff, NodeFailure> {
9152        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9153            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9154        })?;
9155        let root = self.workspace_root(&workspace_id)?;
9156        read_git_diff_bounded(&root, request).await
9157    }
9158
9159    fn native_session_route_root(
9160        &self,
9161        route: &NativeSessionCatalogRoute,
9162    ) -> Result<Option<String>, NodeFailure> {
9163        route
9164            .validate()
9165            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9166        match route.scope {
9167            gate4agent_types::NativeSessionCatalogScope::Workspace => route
9168                .workspace_id
9169                .as_ref()
9170                .map(|workspace_id| self.workspace_root(workspace_id))
9171                .transpose(),
9172            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9173        }
9174    }
9175
9176    fn project_native_session_entries(
9177        &self,
9178        route: &NativeSessionCatalogRoute,
9179        entries: Vec<ScopedNativeSessionCatalogEntry>,
9180    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9181        let current_root = route
9182            .workspace_id
9183            .as_ref()
9184            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9185        let records = self
9186            .session_records
9187            .lock()
9188            .unwrap_or_else(|poisoned| poisoned.into_inner());
9189        let mut seen_records = std::collections::HashSet::new();
9190        entries
9191            .into_iter()
9192            .map(|entry| {
9193                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9194                    let matches = records
9195                        .records
9196                        .values()
9197                        .filter(|record| {
9198                            record.provider == route.provider
9199                                && record.workspace_id == *workspace_id
9200                                && current_root.as_deref().is_some_and(|root| {
9201                                    platform::roots_equal(
9202                                        root,
9203                                        windows_path_text(&record.canonical_root),
9204                                    )
9205                                })
9206                                && record.provider_session.as_ref().is_some_and(|identity| {
9207                                    session_registry::same_provider_session(
9208                                        &record.provider,
9209                                        identity,
9210                                        &route.provider,
9211                                        &entry.provider_session,
9212                                    )
9213                                })
9214                        })
9215                        .map(|record| record.record_id.clone())
9216                        .collect::<Vec<_>>();
9217                    (matches.len() == 1
9218                        && seen_records.insert(matches[0].clone()))
9219                        .then(|| matches[0].clone())
9220                });
9221                let metadata = entry.metadata;
9222                crate::protocol::NativeSessionCatalogEntry {
9223                    selection_id: metadata.selection_id,
9224                    title: metadata.title,
9225                    modified_at_unix_ms: metadata.modified_at_unix_ms,
9226                    model: metadata.model,
9227                    message_count: metadata.message_count,
9228                    completed_turn_count: metadata.completed_turn_count,
9229                    external_group: entry.external_group,
9230                    record_id,
9231                }
9232            })
9233            .collect()
9234    }
9235
9236    async fn catalog_native_sessions(
9237        &self,
9238        route: NativeSessionCatalogRoute,
9239        limit: u16,
9240    ) -> Result<(
9241        Vec<crate::protocol::NativeSessionCatalogEntry>,
9242        crate::protocol::NativeSessionCatalogSummary,
9243    ), NodeFailure> {
9244        let canonical_root = self.native_session_route_root(&route)?;
9245        let registered_workspace_roots = self.workspace_roots();
9246        let provider = route.provider.clone();
9247        let scope = route.scope;
9248        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9249            failure(
9250                NodeFailureCode::InvalidRequest,
9251                "native session catalog is not configured",
9252            )
9253        })?;
9254        let _permit = Arc::clone(&self.inspection_slots)
9255            .try_acquire_owned()
9256            .map_err(|_| {
9257                failure(
9258                    NodeFailureCode::BackendBusy,
9259                    "native session catalog capacity is busy",
9260                )
9261            })?;
9262        let page = run_native_session_catalog_operation(
9263            catalog,
9264            "native session catalog",
9265            move |catalog| {
9266                catalog.catalog_initial_for_scope(
9267                    &provider,
9268                    scope,
9269                    canonical_root.as_deref().map(Path::new),
9270                    &registered_workspace_roots,
9271                    limit,
9272                )
9273            },
9274        )
9275        .await?;
9276        let summary = crate::protocol::NativeSessionCatalogSummary {
9277            catalog_revision: page.revision,
9278            recent_cutoff_unix_ms: page.cutoff_unix_ms,
9279            recent_total_count: page.recent_total_count,
9280            older_total_count: page.older_total_count,
9281            recent_next_after_selection_id: page.next_after_selection_id,
9282            recent_has_more: page.remaining_count > 0,
9283        };
9284        Ok((self.project_native_session_entries(&route, page.entries), summary))
9285    }
9286
9287    async fn page_native_sessions(
9288        &self,
9289        route: NativeSessionCatalogRoute,
9290        window: crate::protocol::NativeSessionCatalogWindow,
9291        catalog_revision: u64,
9292        recent_cutoff_unix_ms: u64,
9293        after_selection_id: Option<String>,
9294        limit: u16,
9295    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9296        let canonical_root = self.native_session_route_root(&route)?;
9297        let registered_workspace_roots = self.workspace_roots();
9298        let provider = route.provider.clone();
9299        let scope = route.scope;
9300        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9301            failure(
9302                NodeFailureCode::InvalidRequest,
9303                "native session catalog is not configured",
9304            )
9305        })?;
9306        let _permit = Arc::clone(&self.inspection_slots)
9307            .try_acquire_owned()
9308            .map_err(|_| {
9309                failure(
9310                    NodeFailureCode::BackendBusy,
9311                    "native session catalog capacity is busy",
9312                )
9313            })?;
9314        let page = run_native_session_catalog_operation(
9315            catalog,
9316            "native session catalog page",
9317            move |catalog| {
9318                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9319                    &provider,
9320                    scope,
9321                    canonical_root.as_deref().map(Path::new),
9322                    &registered_workspace_roots,
9323                    window,
9324                    catalog_revision,
9325                    recent_cutoff_unix_ms,
9326                    after_selection_id.as_deref(),
9327                    limit,
9328                ))
9329            },
9330        )
9331        .await?;
9332        let page = page.map_err(|error| match error {
9333            NativeSessionCatalogError::StaleCatalog => failure(
9334                NodeFailureCode::StaleNativeSessionCatalog,
9335                "native session catalog revision or cursor is stale",
9336            ),
9337            _ => failure(
9338                NodeFailureCode::BackendOperationFailed,
9339                "native session catalog page failed",
9340            ),
9341        })?;
9342        let entries = self.project_native_session_entries(&route, page.entries);
9343        Ok(crate::protocol::NativeSessionCatalogPage {
9344            window: page.window,
9345            revision: page.revision,
9346            entries,
9347            next_after_selection_id: page.next_after_selection_id,
9348            remaining_count: page.remaining_count,
9349            has_more: page.remaining_count > 0,
9350        })
9351    }
9352
9353    async fn preview_native_session(
9354        &self,
9355        selection: NativeSessionSelection,
9356        message_limit: u16,
9357    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9358        selection
9359            .validate()
9360            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9361        let canonical_root = self.native_session_route_root(&selection.route)?;
9362        let registered_workspace_roots = self.workspace_roots();
9363        let provider = selection.route.provider.clone();
9364        let scope = selection.route.scope;
9365        let catalog_revision = selection.catalog_revision;
9366        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9367        let selection_id = selection.selection_id.clone();
9368        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9369            failure(
9370                NodeFailureCode::InvalidRequest,
9371                "native session preview is not configured",
9372            )
9373        })?;
9374        let _permit = Arc::clone(&self.inspection_slots)
9375            .try_acquire_owned()
9376            .map_err(|_| {
9377                failure(
9378                    NodeFailureCode::BackendBusy,
9379                    "native session preview capacity is busy",
9380                )
9381            })?;
9382        let preview = run_native_session_catalog_operation(
9383            catalog,
9384            "native session preview",
9385            move |catalog| {
9386                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9387                    &provider,
9388                    scope,
9389                    canonical_root.as_deref().map(Path::new),
9390                    &registered_workspace_roots,
9391                    catalog_revision,
9392                    recent_cutoff_unix_ms,
9393                    &selection_id,
9394                    message_limit,
9395                ))
9396            },
9397        )
9398        .await?;
9399        preview
9400            .map(Into::into)
9401            .map_err(native_session_preview_failure)
9402    }
9403
9404    async fn index_native_session(
9405        &self,
9406        selection: NativeSessionSelection,
9407        display_name: String,
9408    ) -> Result<ManagedSessionRecord, NodeFailure> {
9409        selection
9410            .validate()
9411            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9412        if selection.route.scope
9413            != gate4agent_types::NativeSessionCatalogScope::Workspace
9414        {
9415            return Err(failure(
9416                NodeFailureCode::WorkspaceRegistrationRequired,
9417                "register the external project as a workspace before indexing or resuming it",
9418            ));
9419        }
9420        let workspace_id = selection
9421            .route
9422            .workspace_id
9423            .clone()
9424            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9425        let canonical_root = self
9426            .native_session_route_root(&selection.route)?
9427            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9428        let registered_workspace_roots = self.workspace_roots();
9429        let provider = selection.route.provider.clone();
9430        let catalog_revision = selection.catalog_revision;
9431        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9432        let selection_id = selection.selection_id.clone();
9433        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9434            failure(
9435                NodeFailureCode::InvalidRequest,
9436                "native session index is not configured",
9437            )
9438        })?;
9439        let _permit = Arc::clone(&self.inspection_slots)
9440            .try_acquire_owned()
9441            .map_err(|_| {
9442                failure(
9443                    NodeFailureCode::BackendBusy,
9444                    "native session index capacity is busy",
9445                )
9446            })?;
9447        let resolved = run_native_session_catalog_operation(
9448            catalog,
9449            "native session index resolution",
9450            move |catalog| {
9451                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9452                    &provider,
9453                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9454                    Some(Path::new(&canonical_root)),
9455                    &registered_workspace_roots,
9456                    catalog_revision,
9457                    recent_cutoff_unix_ms,
9458                    &selection_id,
9459                ))
9460            },
9461        )
9462        .await?
9463        .map_err(native_session_preview_failure)?;
9464        self.index_provider_session_with_policy(
9465            workspace_id,
9466            selection.route.provider,
9467            resolved.identity,
9468            display_name,
9469            true,
9470        )
9471    }
9472
9473    async fn preview_native_session_by_session_id(
9474        &self,
9475        workspace_id: WorkspaceId,
9476        provider: AgentId,
9477        session_id: String,
9478        message_limit: u16,
9479    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9480        let canonical_root = self.workspace_root(&workspace_id)?;
9481        let registered_workspace_roots = self.workspace_roots();
9482        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9483            failure(
9484                NodeFailureCode::InvalidRequest,
9485                "native session preview is not configured",
9486            )
9487        })?;
9488        let _permit = Arc::clone(&self.inspection_slots)
9489            .try_acquire_owned()
9490            .map_err(|_| {
9491                failure(
9492                    NodeFailureCode::BackendBusy,
9493                    "native session preview capacity is busy",
9494                )
9495            })?;
9496        run_native_session_catalog_operation(
9497            catalog,
9498            "native session preview",
9499            move |catalog| {
9500                catalog.preview_session_id_for_workspace(
9501                    &provider,
9502                    Path::new(&canonical_root),
9503                    &registered_workspace_roots,
9504                    &session_id,
9505                    message_limit,
9506                )
9507            },
9508        )
9509        .await
9510    }
9511
9512    async fn register_workspace(
9513        &self,
9514        workspace_id: WorkspaceId,
9515        root: String,
9516    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9517        validate_workspace_request_root(&root)?;
9518        let canonicalize_workspace_id = workspace_id.clone();
9519        let workspace = tokio::task::spawn_blocking(move || {
9520            WorkspaceConfig::new(canonicalize_workspace_id, root)
9521        })
9522        .await
9523        .map_err(|error| {
9524            failure(
9525                NodeFailureCode::BackendOperationFailed,
9526                &format!("workspace canonicalization task failed: {error}"),
9527            )
9528        })?
9529        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9530        let transaction = self
9531            .state_transaction
9532            .lock()
9533            .unwrap_or_else(|poisoned| poisoned.into_inner());
9534        let mut workspaces = self
9535            .workspaces
9536            .write()
9537            .unwrap_or_else(|poisoned| poisoned.into_inner());
9538        if workspaces.contains_key(&workspace_id) {
9539            return Err(failure(
9540                NodeFailureCode::DuplicateWorkspaceId,
9541                "workspace ID is already registered",
9542            ));
9543        }
9544        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9545            platform::roots_equal(existing_root, workspace.canonical_root())
9546        }) {
9547            return Err(failure(
9548                NodeFailureCode::DuplicateWorkspaceRoot,
9549                &format!("workspace root is already registered as '{existing_id}'"),
9550            ));
9551        }
9552        {
9553            let records = self
9554                .session_records
9555                .lock()
9556                .unwrap_or_else(|poisoned| poisoned.into_inner());
9557            if let Some(record) = records.records.values().find(|record| {
9558                record.workspace_id == workspace_id
9559                    && !record
9560                        .canonical_root
9561                        .as_utf8()
9562                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9563            }) {
9564                return Err(failure(
9565                    NodeFailureCode::SessionRecordConflict,
9566                    &format!(
9567                        "workspace ID is retained by session record '{}' at another root",
9568                        record.record_id,
9569                    ),
9570                ));
9571            }
9572        }
9573        let snapshot = WorkspaceSnapshot {
9574            workspace_id: workspace_id.clone(),
9575            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9576            sessions: Vec::new(),
9577            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9578            managed_worktree_profiles: Some(WorktreeProfileInventory {
9579                profiles: Vec::new(),
9580            }),
9581        };
9582        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9583        drop(workspaces);
9584        let previous_records = {
9585            let mut records = self
9586                .session_records
9587                .lock()
9588                .unwrap_or_else(|poisoned| poisoned.into_inner());
9589            let previous = records.records.clone();
9590            for record in records.records.values_mut().filter(|record| {
9591                record.workspace_id == workspace_id
9592                    && record
9593                        .canonical_root
9594                        .as_utf8()
9595                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9596            }) {
9597                if record.active_session.is_none() {
9598                    record.state = detached_record_state(record);
9599                }
9600                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9601                    record.last_error = None;
9602                }
9603                record.updated_at_unix_ms = unix_time_ms();
9604            }
9605            previous
9606        };
9607        if let Err(error) = self.persist_state_locked() {
9608            self.workspaces
9609                .write()
9610                .unwrap_or_else(|poisoned| poisoned.into_inner())
9611                .remove(&workspace_id);
9612            self.session_records
9613                .lock()
9614                .unwrap_or_else(|poisoned| poisoned.into_inner())
9615                .records = previous_records;
9616            return Err(persistence_failure(error));
9617        }
9618        let restored = self
9619            .session_records
9620            .lock()
9621            .unwrap_or_else(|poisoned| poisoned.into_inner())
9622            .records
9623            .values()
9624            .filter(|record| record.workspace_id == workspace_id)
9625            .cloned()
9626            .collect::<Vec<_>>();
9627        drop(transaction);
9628        self.publish(NodeEvent::WorkspaceAdded {
9629            workspace: snapshot.clone(),
9630        });
9631        for record in restored {
9632            self.publish_record(record);
9633        }
9634        Ok(snapshot)
9635    }
9636
9637    async fn create_standalone_workspace(
9638        &self,
9639        workspace_id: WorkspaceId,
9640        root: String,
9641        initial_branch: Option<String>,
9642    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9643        validate_workspace_request_root(&root)?;
9644        validate_git_revision("initial branch", initial_branch.as_deref())?;
9645        let preflight_workspace_id = workspace_id.clone();
9646        let preflight_root = root.clone();
9647        let canonical_candidate = tokio::task::spawn_blocking(move || {
9648            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9649        })
9650        .await
9651        .map_err(|_| failure(
9652            NodeFailureCode::BackendOperationFailed,
9653            "standalone workspace root preflight task failed",
9654        ))?
9655        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9656        {
9657            let workspaces = self
9658                .workspaces
9659                .read()
9660                .unwrap_or_else(|poisoned| poisoned.into_inner());
9661            if workspaces.contains_key(&workspace_id) {
9662                return Err(failure(
9663                    NodeFailureCode::DuplicateWorkspaceId,
9664                    "workspace ID is already registered",
9665                ));
9666            }
9667            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9668                platform::roots_equal(existing_root, &canonical_candidate)
9669            }) {
9670                return Err(failure(
9671                    NodeFailureCode::DuplicateWorkspaceRoot,
9672                    &format!("workspace root is already registered as '{existing_id}'"),
9673                ));
9674            }
9675        }
9676        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9677            .await
9678            .map_err(standalone_workspace_failure)?;
9679        prepared
9680            .verify_for_registration()
9681            .await
9682            .map_err(standalone_workspace_failure)?;
9683        match self
9684            .register_workspace(workspace_id, prepared.root().to_owned())
9685            .await
9686        {
9687            Ok(workspace) => Ok(workspace),
9688            Err(registration_error) => {
9689                if prepared.compensate_registration_failure().await {
9690                    Err(registration_error)
9691                } else {
9692                    Err(failure(
9693                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9694                        "standalone workspace initialization succeeded but registration failed; recovery is required",
9695                    ))
9696                }
9697            }
9698        }
9699    }
9700
9701    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9702        let bindings = self
9703            .session_bindings
9704            .lock()
9705            .unwrap_or_else(|poisoned| poisoned.into_inner());
9706        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9707            return Err(failure(
9708                NodeFailureCode::WorkspaceBusy,
9709                "workspace retains a bound session; remove every session before unregistering it",
9710            ));
9711        }
9712        drop(bindings);
9713        let transaction = self
9714            .state_transaction
9715            .lock()
9716            .unwrap_or_else(|poisoned| poisoned.into_inner());
9717        let mut workspaces = self
9718            .workspaces
9719            .write()
9720            .unwrap_or_else(|poisoned| poisoned.into_inner());
9721        if !workspaces.contains_key(workspace_id) {
9722            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9723        }
9724        if workspaces.len() == 1 {
9725            return Err(failure(
9726                NodeFailureCode::LastWorkspace,
9727                "node must retain at least one workspace",
9728            ));
9729        }
9730        let removed_root = workspaces
9731            .remove(workspace_id)
9732            .expect("workspace presence was validated");
9733        drop(workspaces);
9734        let previous_records = {
9735            let mut records = self
9736                .session_records
9737                .lock()
9738                .unwrap_or_else(|poisoned| poisoned.into_inner());
9739            let previous = records.records.clone();
9740            for record in records
9741                .records
9742                .values_mut()
9743                .filter(|record| &record.workspace_id == workspace_id)
9744            {
9745                record.active_session = None;
9746                record.state = ManagedSessionState::Unavailable;
9747                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9748                record.updated_at_unix_ms = unix_time_ms();
9749            }
9750            previous
9751        };
9752        if let Err(error) = self.persist_state_locked() {
9753            self.workspaces
9754                .write()
9755                .unwrap_or_else(|poisoned| poisoned.into_inner())
9756                .insert(workspace_id.clone(), removed_root);
9757            self.session_records
9758                .lock()
9759                .unwrap_or_else(|poisoned| poisoned.into_inner())
9760                .records = previous_records;
9761            return Err(persistence_failure(error));
9762        }
9763        let unavailable = self
9764            .session_records
9765            .lock()
9766            .unwrap_or_else(|poisoned| poisoned.into_inner())
9767            .records
9768            .values()
9769            .filter(|record| &record.workspace_id == workspace_id)
9770            .cloned()
9771            .collect::<Vec<_>>();
9772        drop(transaction);
9773        self.publish(NodeEvent::WorkspaceRemoved {
9774            workspace_id: workspace_id.clone(),
9775        });
9776        for record in unavailable {
9777            self.publish_record(record);
9778        }
9779        Ok(())
9780    }
9781
9782    async fn create_worktree(
9783        &self,
9784        source_workspace_id: WorkspaceId,
9785        workspace_id: WorkspaceId,
9786        target_root: String,
9787        branch: String,
9788        base: Option<String>,
9789    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9790        self.require_worktree_service(&source_workspace_id)?;
9791        validate_workspace_request_root(&target_root)?;
9792        validate_git_revision("worktree base", base.as_deref())?;
9793        let source_root = self.workspace_root(&source_workspace_id)?;
9794        {
9795            let workspaces = self
9796                .workspaces
9797                .read()
9798                .unwrap_or_else(|poisoned| poisoned.into_inner());
9799            if workspaces.contains_key(&workspace_id) {
9800                return Err(failure(
9801                    NodeFailureCode::DuplicateWorkspaceId,
9802                    "workspace ID is already registered",
9803                ));
9804            }
9805            if let Some((existing_id, _)) = workspaces
9806                .iter()
9807                .find(|(_, root)| worktree_paths_equal(root, &target_root))
9808            {
9809                return Err(failure(
9810                    NodeFailureCode::DuplicateWorkspaceRoot,
9811                    &format!("workspace root is already registered as '{existing_id}'"),
9812                ));
9813            }
9814        }
9815        let mut worktree = create_git_worktree(
9816            &source_root,
9817            &target_root,
9818            &branch,
9819            base.as_deref(),
9820        )
9821        .await
9822        .map_err(git_worktree_failure)?;
9823        let workspace = match self
9824            .register_workspace(workspace_id, worktree.path.clone())
9825            .await
9826        {
9827            Ok(workspace) => workspace,
9828            Err(registration_error) => {
9829                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9830                    return Err(failure(
9831                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
9832                        "worktree registration failed and compensation requires recovery",
9833                    ));
9834                }
9835                return Err(failure(
9836                    registration_error.code,
9837                    &registration_error.message,
9838                ));
9839            }
9840        };
9841        worktree.workspace_id = Some(workspace.workspace_id.clone());
9842        Ok((protocol_worktree(worktree), workspace))
9843    }
9844
9845    async fn remove_worktree(
9846        &self,
9847        source_workspace_id: WorkspaceId,
9848        target_root: String,
9849    ) -> Result<Option<WorkspaceId>, NodeFailure> {
9850        self.require_worktree_service(&source_workspace_id)?;
9851        validate_workspace_request_root(&target_root)?;
9852        let source_root = self.workspace_root(&source_workspace_id)?;
9853        let target_root = normalize_worktree_removal_target(&target_root)
9854            .map_err(git_worktree_failure)?;
9855        let target = list_git_worktrees(&source_root)
9856            .await
9857            .map_err(git_worktree_failure)?
9858            .into_iter()
9859            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9860            .ok_or_else(|| {
9861                failure(
9862                    NodeFailureCode::WorktreeProtected,
9863                    "refusing to remove a path that is not in Git's worktree listing",
9864                )
9865            })?;
9866        let registered_workspace_id = {
9867            let workspaces = self
9868                .workspaces
9869                .read()
9870                .unwrap_or_else(|poisoned| poisoned.into_inner());
9871            let matched = workspaces
9872                .iter()
9873                .find(|(_, root)| worktree_paths_equal(root, &target.path))
9874                .map(|(workspace_id, _)| workspace_id.clone());
9875            if matched.is_some() && workspaces.len() == 1 {
9876                return Err(failure(
9877                    NodeFailureCode::LastWorkspace,
9878                    "node must retain at least one workspace",
9879                ));
9880            }
9881            matched
9882        };
9883        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9884            let bindings = self
9885                .session_bindings
9886                .lock()
9887                .unwrap_or_else(|poisoned| poisoned.into_inner());
9888            if bindings
9889                .values()
9890                .any(|binding| &binding.workspace_id == workspace_id)
9891            {
9892                return Err(failure(
9893                    NodeFailureCode::WorkspaceBusy,
9894                    "worktree workspace retains a bound session; remove every session first",
9895                ));
9896            }
9897        }
9898        remove_git_worktree(&source_root, &target.path)
9899            .await
9900            .map_err(git_worktree_failure)?;
9901        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9902            self.unregister_workspace(workspace_id)
9903                .map_err(|error| failure(error.code, &error.message))?;
9904        }
9905        Ok(registered_workspace_id)
9906    }
9907
9908    async fn cleanup_managed_worktree(
9909        &self,
9910        lease_id: &ManagedWorktreeLeaseId,
9911        explicit: bool,
9912    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9913        let lease = self.managed_worktrees.lock()
9914            .unwrap_or_else(|poisoned| poisoned.into_inner())
9915            .get(lease_id)
9916            .cloned()
9917            .ok_or_else(|| failure(
9918                NodeFailureCode::UnknownManagedWorktreeLease,
9919                "managed worktree lease does not exist",
9920            ))?;
9921        if lease.state == ManagedWorktreeLeaseState::Removed {
9922            return Ok(lease.snapshot());
9923        }
9924        if lease.has_holders() {
9925            return Err(failure(
9926                NodeFailureCode::ManagedWorktreeBusy,
9927                "managed worktree lease still has a session or durable-record holder",
9928            ));
9929        }
9930        if self
9931            .materializations
9932            .lock()
9933            .unwrap_or_else(|poisoned| poisoned.into_inner())
9934            .values()
9935            .any(|record| record.managed_lease_id() == Some(lease_id))
9936        {
9937            return Err(failure(
9938                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9939                "managed worktree cleanup is blocked by session environment ownership",
9940            ));
9941        }
9942        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9943            let snapshot = {
9944                let mut registry = self.managed_worktrees.lock()
9945                    .unwrap_or_else(|poisoned| poisoned.into_inner());
9946                let current = registry.get_mut(lease_id)
9947                    .expect("managed worktree lease remains present");
9948                current.state = ManagedWorktreeLeaseState::Retained;
9949                current.cleanup_failure = None;
9950                current.updated_at_unix_ms = unix_time_ms();
9951                current.snapshot()
9952            };
9953            if self.persist_state().is_err() {
9954                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9955            }
9956            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9957            return Ok(snapshot);
9958        }
9959        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9960        if profile.revision() != &lease.profile_revision {
9961            self.mark_managed_recovery_required(
9962                lease_id,
9963                ManagedWorktreeCleanupFailure::OwnershipConflict,
9964            );
9965            return Err(failure(
9966                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9967                "managed worktree profile revision changed",
9968            ));
9969        }
9970        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9971            self.mark_managed_recovery_required(
9972                lease_id,
9973                ManagedWorktreeCleanupFailure::OwnershipConflict,
9974            );
9975            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9976        })?;
9977        let source_root = self.workspace_root(&lease.source_workspace_id)?;
9978        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9979            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9980            managed_git_worktree_failure(error)
9981        })?;
9982        let target = listed.iter()
9983            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9984        if let Some(target) = target {
9985            if target.is_main || target.is_bare || !exact_owned_worktree(
9986                &lease,
9987                &target.path,
9988                target.branch.as_deref(),
9989                &target.head,
9990            ) {
9991                self.mark_managed_recovery_required(
9992                    lease_id,
9993                    ManagedWorktreeCleanupFailure::OwnershipConflict,
9994                );
9995                return Err(failure(
9996                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
9997                    "Git worktree identity no longer matches the managed lease",
9998                ));
9999            }
10000            if target.prunable {
10001                self.mark_managed_cleanup_blocked(
10002                    lease_id,
10003                    ManagedWorktreeCleanupFailure::Prunable,
10004                );
10005                return Err(failure(
10006                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
10007                    "managed worktree is prunable and requires recovery",
10008                ));
10009            }
10010            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10011                let failure_kind = match error.kind {
10012                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10013                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10014                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10015                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10016                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10017                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10018                };
10019                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10020                    self.mark_managed_recovery_required(lease_id, failure_kind);
10021                } else {
10022                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10023                }
10024                return Err(managed_git_worktree_failure(error));
10025            }
10026        } else {
10027            match std::fs::symlink_metadata(&lease.target_root) {
10028                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10029                Ok(_) => {
10030                    self.mark_managed_recovery_required(
10031                        lease_id,
10032                        ManagedWorktreeCleanupFailure::OwnershipConflict,
10033                    );
10034                    return Err(failure(
10035                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
10036                        "managed target exists but is not owned by Git",
10037                    ));
10038                }
10039                Err(_) => {
10040                    self.mark_managed_cleanup_blocked(
10041                        lease_id,
10042                        ManagedWorktreeCleanupFailure::Backend,
10043                    );
10044                    return Err(failure(
10045                        NodeFailureCode::BackendOperationFailed,
10046                        "managed target identity could not be inspected",
10047                    ));
10048                }
10049            }
10050        }
10051        let registered = self.workspaces.read()
10052            .unwrap_or_else(|poisoned| poisoned.into_inner())
10053            .contains_key(&lease.workspace_id);
10054        if registered {
10055            self.unregister_workspace(&lease.workspace_id)?;
10056        }
10057        let removed = {
10058            self.managed_worktrees.lock()
10059                .unwrap_or_else(|poisoned| poisoned.into_inner())
10060                .tombstone(lease_id, unix_time_ms())
10061                .expect("managed worktree lease remains present during cleanup")
10062        };
10063        self.persist_state().map_err(persistence_failure)?;
10064        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10065        Ok(removed.snapshot())
10066    }
10067
10068    fn mark_managed_cleanup_blocked(
10069        &self,
10070        lease_id: &ManagedWorktreeLeaseId,
10071        failure_kind: ManagedWorktreeCleanupFailure,
10072    ) {
10073        let snapshot = {
10074            let mut registry = self.managed_worktrees.lock()
10075                .unwrap_or_else(|poisoned| poisoned.into_inner());
10076            let Some(lease) = registry.get_mut(lease_id) else { return };
10077            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10078            lease.cleanup_failure = Some(failure_kind);
10079            lease.updated_at_unix_ms = unix_time_ms();
10080            lease.snapshot()
10081        };
10082        let _ = self.persist_state();
10083        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10084    }
10085
10086    fn require_worktree_service(
10087        &self,
10088        source_workspace_id: &WorkspaceId,
10089    ) -> Result<(), NodeFailure> {
10090        if self.managed_worktrees.lock()
10091            .unwrap_or_else(|poisoned| poisoned.into_inner())
10092            .lease_for_workspace(source_workspace_id)
10093            .is_some()
10094        {
10095            return Err(failure(
10096                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10097                "managed worktree targets are internal-only workspaces",
10098            ));
10099        }
10100        match self
10101            .worktree_service_modes
10102            .get(source_workspace_id)
10103            .copied()
10104            .unwrap_or(WorktreeServiceMode::Manual)
10105        {
10106            WorktreeServiceMode::Manual => Ok(()),
10107            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10108                NodeFailureCode::UnsupportedSpawnCapability,
10109                "Git worktree service mode is unavailable in this node build",
10110            )),
10111        }
10112    }
10113
10114    fn managed_reservation_conflict(
10115        &self,
10116        workspace_id: Option<&WorkspaceId>,
10117        root: Option<&str>,
10118    ) -> bool {
10119        self.managed_worktrees.lock()
10120            .unwrap_or_else(|poisoned| poisoned.into_inner())
10121            .active_records()
10122            .any(|lease| {
10123                workspace_id.is_some_and(|workspace_id| {
10124                    workspace_id == &lease.source_workspace_id
10125                        || workspace_id == &lease.workspace_id
10126                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10127            })
10128    }
10129
10130    fn reject_managed_reservation(
10131        &self,
10132        workspace_id: Option<&WorkspaceId>,
10133        root: Option<&str>,
10134    ) -> Result<(), NodeFailure> {
10135        if self.managed_reservation_conflict(workspace_id, root) {
10136            Err(failure(
10137                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10138                "managed worktree ownership may only be mutated through its lease cleanup",
10139            ))
10140        } else {
10141            Ok(())
10142        }
10143    }
10144
10145    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10146        self.validate_address(address)?;
10147        self.workspace_root(&address.workspace_id)
10148    }
10149
10150    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
10151    /// `require_policy`'s own doc on those two requirements), so they keep
10152    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
10153    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
10154    /// `false` on the `require_policy` call below for them because a PTY
10155    /// resume never needs those facts, not because this method cannot look
10156    /// them up.
10157    ///
10158    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
10159    /// the SAME `binding.runtime_policy` every other requirement reads here
10160    /// (one computed policy, read by every gate). That policy is no longer
10161    /// PTY-terminal-verification-shaped for an ACP session --
10162    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
10163    /// `provider_runtime::policy_for_transport` -- so consulting it for
10164    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
10165    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
10166    /// even though a prompt is an ACP session's only interface). Pipe keeps
10167    /// reading the same PTY-probe-derived value it always has --
10168    /// `policy_for_transport` passes Pipe through unchanged this pass (see
10169    /// its own doc for why).
10170    ///
10171    /// What this method still adds on top, for ACP/Pipe only, is a
10172    /// turn-in-flight PRECONDITION ahead of that policy check: a session
10173    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
10174    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
10175    /// read off the same live `SessionSnapshot` state `require_acp_session`
10176    /// already reads for the ACP control verbs -- `TurnStarted` observed
10177    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
10178    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
10179    /// its fate in vendor-specific behaviour this node cannot observe (one
10180    /// agent may queue it, another drop it, another interleave it), so this
10181    /// refuses it outright rather than gambling on that -- a rule with no
10182    /// PTY analogue, which is why PTY does not take this extra precondition.
10183    fn require_session_runtime_policy(
10184        &self,
10185        address: &SessionAddress,
10186        requirement: ProviderRuntimeRequirement,
10187    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10188        let runtime_policy = {
10189            let bindings = self
10190                .session_bindings
10191                .lock()
10192                .unwrap_or_else(|poisoned| poisoned.into_inner());
10193            let binding = bindings
10194                .get(&address.session.instance_id)
10195                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10196            if binding.workspace_id != address.workspace_id {
10197                return Err(failure(
10198                    NodeFailureCode::SessionWorkspaceMismatch,
10199                    "session belongs to another workspace",
10200                ));
10201            }
10202            if binding.generation != address.session.generation {
10203                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10204            }
10205            binding.runtime_policy
10206        };
10207        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10208            let snapshot = self.internal_session_snapshot(address)?;
10209            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10210                && snapshot.provider.lead_activity != ProviderActivity::Idle
10211            {
10212                return Err(failure(
10213                    NodeFailureCode::TurnInFlight,
10214                    "session already has a turn in flight; wait for it to complete before sending another prompt",
10215                ));
10216            }
10217        }
10218        require_policy(runtime_policy, false, false, requirement)
10219            .map(|()| runtime_policy)
10220            .map_err(|_| failure(
10221                NodeFailureCode::UnsupportedCapability,
10222                "session runtime policy does not admit this semantic operation",
10223            ))
10224    }
10225
10226    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10227        let mut bindings = self
10228            .session_bindings
10229            .lock()
10230            .unwrap_or_else(|poisoned| poisoned.into_inner());
10231        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10232            binding.workspace_id == address.workspace_id
10233                && binding.generation == address.session.generation
10234        }) {
10235            let removed = bindings.remove(&address.session.instance_id);
10236            self.clear_terminal_frame_watermark(address);
10237            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10238            return removed;
10239        }
10240        None
10241    }
10242
10243    fn cleanup_session_owned_materialization(
10244        &self,
10245        address: &SessionAddress,
10246        binding: &SessionBinding,
10247    ) -> Result<(), NodeFailure> {
10248        let Some(id) = binding.materialization_id.as_ref() else {
10249            return Ok(());
10250        };
10251        let owner = self
10252            .materializations
10253            .lock()
10254            .unwrap_or_else(|poisoned| poisoned.into_inner())
10255            .get(id)
10256            .map(|record| record.owner().clone());
10257        match owner {
10258            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10259            Some(MaterializationOwner::Session {
10260                incarnation_id,
10261                instance_id,
10262                generation,
10263            }) if incarnation_id == self.incarnation_id
10264                && instance_id == address.session.instance_id
10265                && generation == address.session.generation =>
10266            {
10267                self.cleanup_materialization(id)
10268            }
10269            Some(MaterializationOwner::Session { .. }) => Err(failure(
10270                NodeFailureCode::BackendOperationFailed,
10271                "session environment ownership requires recovery",
10272            )),
10273        }
10274    }
10275
10276    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10277        let mut watermarks = self
10278            .terminal_frame_watermarks
10279            .lock()
10280            .unwrap_or_else(|poisoned| poisoned.into_inner());
10281        if watermarks
10282            .get(&address.session.instance_id)
10283            .is_some_and(|(current, _)| current == address)
10284        {
10285            watermarks.remove(&address.session.instance_id);
10286        }
10287    }
10288
10289    #[cfg(test)]
10290    fn address_for(
10291        &self,
10292        instance_id: AgentInstanceId,
10293        generation: SessionGeneration,
10294    ) -> Option<SessionAddress> {
10295        let binding = self.session_bindings
10296            .lock()
10297            .unwrap_or_else(|poisoned| poisoned.into_inner())
10298            .get(&instance_id)
10299            .cloned()?;
10300        (binding.generation == generation).then_some(SessionAddress {
10301            workspace_id: binding.workspace_id,
10302            session: SessionKey { instance_id, generation },
10303        })
10304    }
10305
10306    fn arm_resume(
10307        &self,
10308        address: &SessionAddress,
10309        command_id: CommandId,
10310        runtime_policy: ProviderRuntimePolicy,
10311    ) -> Result<(), NodeFailure> {
10312        let mut bindings = self
10313            .session_bindings
10314            .lock()
10315            .unwrap_or_else(|poisoned| poisoned.into_inner());
10316        let binding = bindings
10317            .get_mut(&address.session.instance_id)
10318            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10319        if binding.workspace_id != address.workspace_id {
10320            return Err(failure(
10321                NodeFailureCode::SessionWorkspaceMismatch,
10322                "session belongs to another workspace",
10323            ));
10324        }
10325        if binding.generation != address.session.generation {
10326            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10327        }
10328        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10329        Ok(())
10330    }
10331
10332    fn clear_armed_resume(&self, address: &SessionAddress) {
10333        let mut bindings = self
10334            .session_bindings
10335            .lock()
10336            .unwrap_or_else(|poisoned| poisoned.into_inner());
10337        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10338            if binding.workspace_id == address.workspace_id
10339                && binding.generation == address.session.generation
10340            {
10341                binding.pending_resume = None;
10342            }
10343        }
10344    }
10345
10346    fn publish_control(&self, event: ControlEvent) {
10347        let revokes_harness_mcp = matches!(
10348            &event.event,
10349            ControlEventKind::Exited { .. }
10350                | ControlEventKind::Failed { .. }
10351                | ControlEventKind::Removed
10352        );
10353        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10354            let mut bindings = self
10355                .session_bindings
10356                .lock()
10357                .unwrap_or_else(|poisoned| poisoned.into_inner());
10358            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10359                return;
10360            };
10361            let previous_address = SessionAddress {
10362                workspace_id: binding.workspace_id.clone(),
10363                session: SessionKey {
10364                    instance_id: event.instance_id,
10365                    generation: binding.generation,
10366                },
10367            };
10368            let mut resume_command_rejected = false;
10369            if binding.generation == event.generation {
10370                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10371                    let rejected_resume = matches!(
10372                        &event.event,
10373                        ControlEventKind::CommandRejected { .. }
10374                    ) && event.command_id == Some(pending_command_id);
10375                    let resume_failed = matches!(
10376                        &event.event,
10377                        ControlEventKind::ResumeDenied { .. }
10378                            | ControlEventKind::ResumeFailed { .. }
10379                    );
10380                    if pending_generation == binding.generation
10381                        && (rejected_resume || resume_failed)
10382                    {
10383                        resume_command_rejected = rejected_resume;
10384                        binding.pending_resume = None;
10385                    }
10386                }
10387            } else {
10388                let expected = binding.generation.0.checked_add(1);
10389                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10390                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10391                    generation == binding.generation
10392                })
10393                    || expected != Some(event.generation.0)
10394                    || !authorized
10395                {
10396                    return;
10397                }
10398                let (_, _, runtime_policy) = binding
10399                    .pending_resume
10400                    .expect("authorized resume retains its pending runtime policy");
10401                binding.generation = event.generation;
10402                binding.runtime_policy = runtime_policy;
10403                binding.pending_resume = None;
10404            }
10405            (
10406                SessionAddress {
10407                    workspace_id: binding.workspace_id.clone(),
10408                    session: SessionKey {
10409                        instance_id: event.instance_id,
10410                        generation: event.generation,
10411                    },
10412                },
10413                (previous_address.session.generation != event.generation)
10414                    .then_some(previous_address),
10415                binding.record_id.clone(),
10416                binding.runtime_policy.provider_session_identity,
10417                resume_command_rejected,
10418            )
10419        };
10420        if let Some(previous_address) = previous_address.as_ref() {
10421            self.clear_terminal_frame_watermark(previous_address);
10422        }
10423        if let Some(record_id) = record_id {
10424            self.reconcile_managed_record(
10425                &record_id,
10426                &address,
10427                &event,
10428                identity_admitted,
10429                resume_command_rejected,
10430            );
10431        }
10432        // Every state change a session's process lifecycle can reach gets a
10433        // line here: this is the single chokepoint every `ControlEventKind`
10434        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10435        // came from a fresh spawn or a resume. Per-message provider chatter
10436        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10437        // matched here -- that is exactly the per-frame noise this crate
10438        // family's level discipline forbids at INFO.
10439        match &event.event {
10440            ControlEventKind::Running { process_id } => {
10441                #[cfg(unix)]
10442                if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10443                    proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10444                }
10445                tracing::info!(
10446                    node_id = %self.node_id,
10447                    workspace_id = %address.workspace_id,
10448                    session = ?address.session,
10449                    pid = ?process_id,
10450                    "session process is running",
10451                );
10452            }
10453            ControlEventKind::Exited { exit_code, forced } => {
10454                #[cfg(unix)]
10455                if let Some(proxy) = self.signer_proxy.get().cloned() {
10456                    let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10457                    if proxy.has_session(&key) {
10458                        // Revoke at core off the event path (blocking uplink call).
10459                        std::thread::spawn(move || proxy.release(&key));
10460                    }
10461                }
10462                tracing::info!(
10463                    node_id = %self.node_id,
10464                    workspace_id = %address.workspace_id,
10465                    session = ?address.session,
10466                    exit_code = ?exit_code,
10467                    forced = forced,
10468                    "session process exited",
10469                );
10470            }
10471            ControlEventKind::Failed { message } => {
10472                tracing::warn!(
10473                    node_id = %self.node_id,
10474                    workspace_id = %address.workspace_id,
10475                    session = ?address.session,
10476                    cause = %message,
10477                    "session failed",
10478                );
10479            }
10480            ControlEventKind::CommandRejected { message } => {
10481                tracing::warn!(
10482                    node_id = %self.node_id,
10483                    workspace_id = %address.workspace_id,
10484                    session = ?address.session,
10485                    cause = %message,
10486                    "session command rejected",
10487                );
10488            }
10489            ControlEventKind::Removed => {
10490                tracing::info!(
10491                    node_id = %self.node_id,
10492                    workspace_id = %address.workspace_id,
10493                    session = ?address.session,
10494                    "session removed",
10495                );
10496            }
10497            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10498                tracing::info!(
10499                    node_id = %self.node_id,
10500                    workspace_id = %address.workspace_id,
10501                    session = ?address.session,
10502                    provider_session = ?provider_session,
10503                    "session resume authorized",
10504                );
10505            }
10506            ControlEventKind::Resumed { session: provider_session, process_id } => {
10507                tracing::info!(
10508                    node_id = %self.node_id,
10509                    workspace_id = %address.workspace_id,
10510                    session = ?address.session,
10511                    provider_session = ?provider_session,
10512                    pid = ?process_id,
10513                    "session resumed",
10514                );
10515            }
10516            ControlEventKind::ResumeDenied { reason } => {
10517                tracing::warn!(
10518                    node_id = %self.node_id,
10519                    workspace_id = %address.workspace_id,
10520                    session = ?address.session,
10521                    cause = %reason,
10522                    "session resume denied",
10523                );
10524            }
10525            ControlEventKind::ResumeFailed { message } => {
10526                tracing::warn!(
10527                    node_id = %self.node_id,
10528                    workspace_id = %address.workspace_id,
10529                    session = ?address.session,
10530                    cause = %message,
10531                    "session resume failed",
10532                );
10533            }
10534            _ => {}
10535        }
10536        self.apply_pending_acp_options(&address, &event);
10537        let stream_chunk = agent_stream_chunk(&event);
10538        self.publish(NodeEvent::Control {
10539            address: address.clone(),
10540            event,
10541        });
10542        if let Some(chunk) = stream_chunk {
10543            self.publish_agent_stream_chunk(address.clone(), chunk);
10544        }
10545        if revokes_harness_mcp {
10546            SessionLifecycleHook::on_session_ended(self, &address);
10547        }
10548    }
10549
10550    fn reconcile_managed_record(
10551        &self,
10552        record_id: &SessionRecordId,
10553        address: &SessionAddress,
10554        event: &ControlEvent,
10555        identity_admitted: bool,
10556        resume_command_rejected: bool,
10557    ) {
10558        let _transaction = self
10559            .state_transaction
10560            .lock()
10561            .unwrap_or_else(|poisoned| poisoned.into_inner());
10562        let observed_identity = match &event.event {
10563            ControlEventKind::ProviderEvent {
10564                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10565                ..
10566            } if identity_admitted => Some(identity.clone()),
10567            _ => None,
10568        };
10569        let identity_was_observed = observed_identity.is_some();
10570        let replacement_id = observed_identity
10571            .as_ref()
10572            .and_then(|_| self.allocate_record_id().ok());
10573        let mut upserts = Vec::new();
10574        let mut removals = Vec::new();
10575        let mut rebind = None;
10576        let original_records;
10577        let original_materializations = self
10578            .materializations
10579            .lock()
10580            .unwrap_or_else(|poisoned| poisoned.into_inner())
10581            .clone();
10582        {
10583            let mut records = self
10584                .session_records
10585                .lock()
10586                .unwrap_or_else(|poisoned| poisoned.into_inner());
10587            original_records = records.records.clone();
10588            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10589                return;
10590            };
10591            if let Some(identity) = observed_identity {
10592                let existing_id = records
10593                    .records
10594                    .values()
10595                    .find(|record| {
10596                        record.record_id != *record_id
10597                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10598                                session_registry::same_provider_session(
10599                                    &record.provider,
10600                                    record_identity,
10601                                    &current_snapshot.provider,
10602                                    &identity,
10603                                )
10604                            })
10605                    })
10606                    .map(|record| record.record_id.clone());
10607                if let Some(existing_id) = existing_id {
10608                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10609                        record.mode == current_snapshot.mode
10610                            && record.workspace_id == current_snapshot.workspace_id
10611                            && record.environment_profile == current_snapshot.environment_profile
10612                            && record.bundle == current_snapshot.bundle
10613                            && record.context_id == current_snapshot.context_id
10614                            && record.context == current_snapshot.context
10615                            && record
10616                                .canonical_root
10617                                .as_utf8()
10618                                .zip(current_snapshot.canonical_root.as_utf8())
10619                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
10620                    });
10621                    if !scope_matches {
10622                        if let Some(current) = records.records.get_mut(record_id) {
10623                            current.state = ManagedSessionState::Unavailable;
10624                            current.last_error =
10625                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10626                            current.updated_at_unix_ms = unix_time_ms();
10627                            upserts.push(current.clone());
10628                        }
10629                    } else {
10630                        let existing_is_active = records
10631                            .records
10632                            .get(&existing_id)
10633                            .is_some_and(|record| record.active_session.is_some());
10634                        if existing_is_active {
10635                            if let Some(current) = records.records.get_mut(record_id) {
10636                                current.state = ManagedSessionState::Unavailable;
10637                                current.last_error =
10638                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10639                                current.updated_at_unix_ms = unix_time_ms();
10640                                upserts.push(current.clone());
10641                            }
10642                        } else {
10643                            if current_snapshot.provider_session.is_some() {
10644                                if let Some(current) = records.records.get_mut(record_id) {
10645                                    current.active_session = None;
10646                                    current.state = ManagedSessionState::Dormant;
10647                                    current.updated_at_unix_ms = unix_time_ms();
10648                                    upserts.push(current.clone());
10649                                }
10650                            } else {
10651                                records.records.remove(record_id);
10652                                removals.push(record_id.clone());
10653                            }
10654                            if let Some(existing) = records.records.get_mut(&existing_id) {
10655                                existing.provider_session = Some(identity.clone());
10656                                existing.active_session = Some(address.clone());
10657                                existing.state = ManagedSessionState::Live;
10658                                existing.updated_at_unix_ms = unix_time_ms();
10659                                existing.last_error = None;
10660                                upserts.push(existing.clone());
10661                            }
10662                            rebind = Some(existing_id);
10663                        }
10664                    }
10665                } else if current_snapshot
10666                    .provider_session
10667                    .as_ref()
10668                    .is_some_and(|current| {
10669                        !session_registry::same_provider_session(
10670                            &current_snapshot.provider,
10671                            current,
10672                            &current_snapshot.provider,
10673                            &identity,
10674                        )
10675                    })
10676                {
10677                    if let Some(current) = records.records.get_mut(record_id) {
10678                        current.active_session = None;
10679                        if current.bundle.is_some() || current.context.is_some() {
10680                            let had_bundle = current.bundle.is_some();
10681                            current.provider_session = None;
10682                            current.bundle = None;
10683                            current.context_id = None;
10684                            current.context = None;
10685                            current.state = ManagedSessionState::Unavailable;
10686                            current.last_error = Some(if had_bundle {
10687                                "bundle-unavailable".to_owned()
10688                            } else {
10689                                "context-unavailable".to_owned()
10690                            });
10691                        } else {
10692                            current.state = ManagedSessionState::Dormant;
10693                        }
10694                        current.updated_at_unix_ms = unix_time_ms();
10695                        upserts.push(current.clone());
10696                    }
10697                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10698                        if let Some(current) = records.records.get_mut(record_id) {
10699                            current.state = ManagedSessionState::Unavailable;
10700                            current.last_error =
10701                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10702                            current.updated_at_unix_ms = unix_time_ms();
10703                            upserts.push(current.clone());
10704                        }
10705                    } else if let Some(new_record_id) = replacement_id {
10706                        let now = unix_time_ms();
10707                        let replacement = ManagedSessionRecord {
10708                            record_id: new_record_id.clone(),
10709                            display_name: format!(
10710                                "{} #{}",
10711                                current_snapshot.provider.as_str(),
10712                                records
10713                                    .records
10714                                    .values()
10715                                    .filter(|record| {
10716                                        record.provider == current_snapshot.provider
10717                                    })
10718                                    .count()
10719                                    .saturating_add(1),
10720                            ),
10721                            provider: current_snapshot.provider.clone(),
10722                            mode: current_snapshot.mode,
10723                            state: ManagedSessionState::Live,
10724                            workspace_id: current_snapshot.workspace_id,
10725                            canonical_root: current_snapshot.canonical_root,
10726                            provider_session: Some(identity),
10727                            active_session: Some(address.clone()),
10728                            environment_profile: current_snapshot.environment_profile,
10729                            bundle: current_snapshot.bundle,
10730                            context_id: current_snapshot.context_id,
10731                            context: current_snapshot.context,
10732                            // This is a fresh record_id representing the session under its
10733                            // newly-observed identity; it has not yet produced any clean
10734                            // exit of its own, so it starts with no exported pack (the old
10735                            // record, still addressable under its own record_id, keeps
10736                            // whatever exported_context it already had).
10737                            exported_context: None,
10738                            task_binding: current_snapshot.task_binding,
10739                            created_at_unix_ms: now,
10740                            updated_at_unix_ms: now,
10741                            last_error: None,
10742                        };
10743                        records
10744                            .records
10745                            .insert(new_record_id.clone(), replacement.clone());
10746                        upserts.push(replacement);
10747                        rebind = Some(new_record_id);
10748                    } else if let Some(current) = records.records.get_mut(record_id) {
10749                        current.state = ManagedSessionState::Unavailable;
10750                        current.last_error =
10751                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10752                        upserts.push(current.clone());
10753                    }
10754                } else if let Some(current) = records.records.get_mut(record_id) {
10755                    current.provider_session = Some(identity);
10756                    current.active_session = Some(address.clone());
10757                    current.state = ManagedSessionState::Live;
10758                    current.updated_at_unix_ms = unix_time_ms();
10759                    current.last_error = None;
10760                    upserts.push(current.clone());
10761                }
10762            } else if let Some(current) = records.records.get_mut(record_id) {
10763                if resume_command_rejected {
10764                    current.active_session = None;
10765                    current.state = detached_record_state(current);
10766                    current.updated_at_unix_ms = unix_time_ms();
10767                    current.last_error =
10768                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10769                    upserts.push(current.clone());
10770                } else {
10771                    match &event.event {
10772                    ControlEventKind::Running { .. }
10773                    | ControlEventKind::ResumeAuthorized { .. }
10774                    | ControlEventKind::Resumed { .. } => {
10775                        current.active_session = Some(address.clone());
10776                        current.state = active_record_state(
10777                            identity_admitted,
10778                            current.provider_session.is_some(),
10779                        );
10780                        current.updated_at_unix_ms = unix_time_ms();
10781                        current.last_error = None;
10782                        upserts.push(current.clone());
10783                    }
10784                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10785                        current.active_session = None;
10786                        current.state = detached_record_state(current);
10787                        current.updated_at_unix_ms = unix_time_ms();
10788                        upserts.push(current.clone());
10789                    }
10790                    ControlEventKind::Failed { message }
10791                    | ControlEventKind::ResumeFailed { message } => {
10792                        current.active_session = None;
10793                        current.state = detached_record_state(current);
10794                        current.updated_at_unix_ms = unix_time_ms();
10795                        current.last_error = Some(
10796                            session_registry::sanitized_record_error_summary(message),
10797                        );
10798                        upserts.push(current.clone());
10799                    }
10800                    ControlEventKind::ResumeDenied { reason } => {
10801                        current.active_session = None;
10802                        current.state = detached_record_state(current);
10803                        current.updated_at_unix_ms = unix_time_ms();
10804                        current.last_error = Some(
10805                            session_registry::sanitized_record_error_summary(reason),
10806                        );
10807                        upserts.push(current.clone());
10808                    }
10809                        _ => {}
10810                    }
10811                }
10812            }
10813        }
10814        let materialization_owner_record = identity_was_observed.then(|| {
10815            rebind.clone().unwrap_or_else(|| record_id.clone())
10816        }).and_then(|candidate| {
10817            self.session_records
10818                .lock()
10819                .unwrap_or_else(|poisoned| poisoned.into_inner())
10820                .records
10821                .get(&candidate)
10822                .filter(|record| {
10823                    record.provider_session.is_some()
10824                        && record.active_session.as_ref() == Some(address)
10825                })
10826                .map(|_| candidate)
10827        });
10828        if let Some(owner_record_id) = materialization_owner_record {
10829            let materialization_id = self
10830                .session_bindings
10831                .lock()
10832                .unwrap_or_else(|poisoned| poisoned.into_inner())
10833                .get(&address.session.instance_id)
10834                .and_then(|binding| binding.materialization_id.clone());
10835            if let Some(materialization_id) = materialization_id {
10836                let mut transfer_failed = false;
10837                {
10838                    let mut materializations = self
10839                        .materializations
10840                        .lock()
10841                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10842                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
10843                        match ownership.owner() {
10844                            MaterializationOwner::Session {
10845                                incarnation_id,
10846                                instance_id,
10847                                generation,
10848                            } if *incarnation_id == self.incarnation_id
10849                                && *instance_id == address.session.instance_id
10850                                && *generation == address.session.generation =>
10851                            {
10852                                transfer_failed = ownership
10853                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10854                                    .is_err();
10855                            }
10856                            MaterializationOwner::Record { record_id }
10857                                if record_id == &owner_record_id => {}
10858                            MaterializationOwner::Record { record_id } => {
10859                                let expected = record_id.clone();
10860                                transfer_failed = ownership
10861                                    .transfer_record_owner(
10862                                        &expected,
10863                                        owner_record_id.clone(),
10864                                        unix_time_ms(),
10865                                    )
10866                                    .is_err();
10867                            }
10868                            _ => transfer_failed = true,
10869                        }
10870                        if transfer_failed {
10871                            let _ = ownership.mark_recovery_required(unix_time_ms());
10872                        }
10873                    } else {
10874                        transfer_failed = true;
10875                    }
10876                }
10877                if transfer_failed {
10878                    if let Some(record) = self
10879                        .session_records
10880                        .lock()
10881                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10882                        .records
10883                        .get_mut(&owner_record_id)
10884                    {
10885                        record.state = ManagedSessionState::Unavailable;
10886                        record.last_error = Some("environment-profile-unavailable".to_owned());
10887                        record.updated_at_unix_ms = unix_time_ms();
10888                        upserts.push(record.clone());
10889                    }
10890                }
10891            }
10892        }
10893        if let Some(new_record_id) = rebind.clone() {
10894            if let Some(binding) = self
10895                .session_bindings
10896                .lock()
10897                .unwrap_or_else(|poisoned| poisoned.into_inner())
10898                .get_mut(&address.session.instance_id)
10899            {
10900                binding.record_id = Some(new_record_id);
10901            }
10902        }
10903        if upserts.is_empty() && removals.is_empty() {
10904            return;
10905        }
10906        if self.persist_state_locked().is_err() {
10907            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10908            let mut records = self
10909                .session_records
10910                .lock()
10911                .unwrap_or_else(|poisoned| poisoned.into_inner());
10912            records.records = original_records;
10913            *self
10914                .materializations
10915                .lock()
10916                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10917            if let Some(current) = records.records.get_mut(record_id) {
10918                current.state = ManagedSessionState::Unavailable;
10919                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10920                current.updated_at_unix_ms = unix_time_ms();
10921                upserts = vec![current.clone()];
10922            } else {
10923                upserts.clear();
10924            }
10925            removals.clear();
10926            drop(records);
10927            if let Some(binding) = self
10928                .session_bindings
10929                .lock()
10930                .unwrap_or_else(|poisoned| poisoned.into_inner())
10931                .get_mut(&address.session.instance_id)
10932            {
10933                binding.record_id = Some(record_id.clone());
10934            }
10935            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10936        }
10937        for record in upserts {
10938            self.publish_record(record);
10939        }
10940        for record_id in removals {
10941            self.publish(NodeEvent::SessionRecordRemoved { record_id });
10942        }
10943    }
10944
10945    fn snapshot(&self) -> NodeSnapshot {
10946        let control = self.handle.snapshot();
10947        let bindings = self
10948            .session_bindings
10949            .lock()
10950            .unwrap_or_else(|poisoned| poisoned.into_inner())
10951            .clone();
10952        let workspace_roots = self
10953            .workspaces
10954            .read()
10955            .unwrap_or_else(|poisoned| poisoned.into_inner())
10956            .clone();
10957        let environment_profiles = self
10958            .environment_profiles
10959            .read()
10960            .unwrap_or_else(|poisoned| poisoned.into_inner());
10961        let mut workspaces = workspace_roots
10962            .iter()
10963            .map(|(workspace_id, canonical_root)| {
10964                (
10965                    workspace_id.clone(),
10966                    WorkspaceSnapshot {
10967                        workspace_id: workspace_id.clone(),
10968                        canonical_root: opaque_windows_path(canonical_root.clone()),
10969                        sessions: Vec::new(),
10970                        worktree_service_mode: Some(
10971                            self.worktree_service_modes
10972                                .get(workspace_id)
10973                                .copied()
10974                                .unwrap_or(WorktreeServiceMode::Manual),
10975                        ),
10976                        managed_worktree_profiles: Some(WorktreeProfileInventory {
10977                            profiles: self
10978                                .managed_worktree_profiles
10979                                .get(workspace_id)
10980                                .into_iter()
10981                                .flat_map(BTreeMap::values)
10982                                .map(|profile| ManagedWorktreeProfileSummary {
10983                                    id: profile.profile_id().clone(),
10984                                    revision: profile.revision().clone(),
10985                                    retention: profile.retention(),
10986                                })
10987                                .collect(),
10988                        }),
10989                    },
10990                )
10991            })
10992            .collect::<BTreeMap<_, _>>();
10993        let mut agent_progress = Vec::new();
10994        for session in &control.sessions {
10995            let Some(binding) = bindings.get(&session.instance_id) else {
10996                continue;
10997            };
10998            if binding.generation != session.generation {
10999                continue;
11000            }
11001            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11002                let address = SessionAddress {
11003                    workspace_id: binding.workspace_id.clone(),
11004                    session: SessionKey {
11005                        instance_id: session.instance_id,
11006                        generation: session.generation,
11007                    },
11008                };
11009                if let Some(progress) =
11010                    agent_progress_from_provider_snapshot(address, &session.provider)
11011                {
11012                    agent_progress.push(progress);
11013                }
11014                workspace.sessions.push(session.clone());
11015            }
11016        }
11017        NodeSnapshot {
11018            node_id: self.node_id.clone(),
11019            enabled_providers: self.enabled_providers.clone(),
11020            provider_runtime_statuses: {
11021                let updates = self
11022                    .provider_runtime_status_updates
11023                    .read()
11024                    .unwrap_or_else(|poisoned| poisoned.into_inner());
11025                ProviderRuntimeStatuses::new(
11026                    self.provider_runtime_statuses
11027                        .iter()
11028                        .filter(|status| !updates.contains_key(status.provider()))
11029                        .cloned()
11030                        .chain(updates.values().cloned()),
11031                )
11032                .expect("refreshed provider runtime inventory remains bounded and unique")
11033            },
11034            workspaces: workspaces.into_values().collect(),
11035            session_records: self
11036                .session_records
11037                .lock()
11038                .unwrap_or_else(|poisoned| poisoned.into_inner())
11039                .records
11040                .values()
11041                .cloned()
11042                .collect(),
11043            managed_worktrees: self.managed_worktrees
11044                .lock()
11045                .unwrap_or_else(|poisoned| poisoned.into_inner())
11046                .snapshots(),
11047            launch_inventory: Some(LaunchInventory {
11048                spawn_profiles: Some(
11049                    self.spawn_profiles
11050                        .iter()
11051                        .filter_map(|profile| {
11052                            resolved_spawn_profile_summary(profile, &environment_profiles)
11053                        })
11054                        .collect(),
11055                ),
11056                bundles: Some(
11057                    self.bundle_catalog
11058                        .read()
11059                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11060                        .iter()
11061                        .map(NodeBundle::receipt)
11062                        .collect(),
11063                ),
11064                // Opaque station allowlist ids from empty-default catalog /
11065                // optional startup loader. Never secrets. Dig2 probe stays stub.
11066                network_allowlists: {
11067                    let catalog = self
11068                        .network_allowlist_catalog
11069                        .read()
11070                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11071                    if catalog.is_empty() {
11072                        None
11073                    } else {
11074                        Some(catalog.ids().cloned().collect())
11075                    }
11076                },
11077            }),
11078            agent_progress,
11079        }
11080    }
11081
11082    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11083        assert!(
11084            !matches!(
11085                &event,
11086                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11087            ),
11088            "terminal frame and agent stream events must use their own dedicated live channel",
11089        );
11090        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11091        let sequence = history
11092            .last_sequence
11093            .checked_add(1)
11094            .expect("node event sequence exhausted");
11095        if history.events.len() == NODE_EVENT_HISTORY_MAX {
11096            if let Some(evicted) = history.events.pop_front() {
11097                history.replay_floor_sequence = evicted
11098                    .sequence
11099                    .checked_add(1)
11100                    .expect("node replay floor sequence exhausted");
11101                history.removed_record_providers.remove(&evicted.sequence);
11102            }
11103        }
11104        match &event {
11105            NodeEvent::HarnessMcpReadCall { .. } => {
11106                panic!("transient harness MCP calls must not enter durable event history")
11107            }
11108            NodeEvent::SessionRecordUpserted { record } => {
11109                history
11110                    .record_providers
11111                    .insert(record.record_id.clone(), record.provider.clone());
11112            }
11113            NodeEvent::SessionRecordRemoved { record_id } => {
11114                if let Some(provider) = history.record_providers.remove(record_id) {
11115                    history.removed_record_providers.insert(sequence, provider);
11116                }
11117            }
11118            NodeEvent::ManagedWorktreeUpserted { .. }
11119            | NodeEvent::ManagedWorktreeRemoved { .. }
11120            | NodeEvent::Control { .. }
11121            | NodeEvent::ControllerChanged { .. }
11122            | NodeEvent::SessionRecordHistorySummarized { .. }
11123            | NodeEvent::WorkspaceAdded { .. }
11124            | NodeEvent::WorkspaceRemoved { .. }
11125            | NodeEvent::ResyncRequired { .. }
11126            | NodeEvent::AgentStream { .. }
11127            | NodeEvent::TerminalFrame { .. } => {}
11128        }
11129        let envelope = NodeEventEnvelope { sequence, event };
11130        history.events.push_back(envelope.clone());
11131        history.last_sequence = sequence;
11132        let _ = self.event_tx.send(envelope.clone());
11133        drop(history);
11134        envelope
11135    }
11136
11137    /// Publishes a transient (never durable) `NodeEvent` -- today only
11138    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
11139    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
11140    /// why this must never ride the shared `event_tx` bus). Returns whether
11141    /// the send reached at least one subscriber: `broadcast::Sender::send`
11142    /// fails in exactly one way, no active receiver at that instant, and
11143    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
11144    /// return to fail the pending call immediately rather than leave it to
11145    /// expire silently at `LOCAL_READ_TIMEOUT`/
11146    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
11147    fn publish_transient(&self, event: NodeEvent) -> bool {
11148        assert!(event.requires_harness_mcp_proxy_capability());
11149        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11150        let envelope = NodeEventEnvelope { sequence: 0, event };
11151        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11152            Ok(_) => true,
11153            Err(broadcast::error::SendError(undelivered)) => {
11154                if let Some(NodeEventEnvelope {
11155                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11156                    ..
11157                }) = undelivered.first() {
11158                    // `request.content_type` only, never a per-tool label --
11159                    // this crate does not decode `request.body` and does not
11160                    // hold `hatchery-harness-api` (Nested Control Plane
11161                    // doctrine, Law 3; see this crate's dependency on
11162                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
11163                    tracing::warn!(
11164                        call_id = call_id.as_str(),
11165                        content_type = ?request.content_type,
11166                        cause = "no-subscribers",
11167                        "harness MCP proxy event could not be published",
11168                    );
11169                }
11170                false
11171            }
11172        }
11173    }
11174
11175    fn publish_terminal_frames(&self) {
11176        let snapshot = self.handle.snapshot();
11177        let bindings = self
11178            .session_bindings
11179            .lock()
11180            .unwrap_or_else(|poisoned| poisoned.into_inner());
11181        let candidates = snapshot
11182            .sessions
11183            .iter()
11184            .filter_map(|session| {
11185                let binding = bindings.get(&session.instance_id)?;
11186                if binding.generation != session.generation {
11187                    return None;
11188                }
11189                let frame = session.terminal_frame.clone()?;
11190                crate::session_signals::feed(
11191                    binding.workspace_id.as_str(),
11192                    session.instance_id.0,
11193                    session.agent_id.as_str(),
11194                    &frame.contents,
11195                );
11196                Some((
11197                    SessionAddress {
11198                        workspace_id: binding.workspace_id.clone(),
11199                        session: SessionKey {
11200                            instance_id: session.instance_id,
11201                            generation: session.generation,
11202                        },
11203                    },
11204                    frame,
11205                ))
11206            })
11207            .collect();
11208        drop(bindings);
11209        self.publish_terminal_frame_candidates(candidates);
11210    }
11211
11212    fn publish_terminal_frame_candidates(
11213        &self,
11214        candidates: Vec<(SessionAddress, TerminalFrame)>,
11215    ) {
11216        let mut watermarks = self
11217            .terminal_frame_watermarks
11218            .lock()
11219            .unwrap_or_else(|poisoned| poisoned.into_inner());
11220        let mut events = Vec::with_capacity(candidates.len());
11221        for (address, frame) in candidates {
11222            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11223            let advanced = watermarks
11224                .get(&address.session.instance_id)
11225                .map_or(true, |(current_address, sequence)| {
11226                    current_address != &address || frame.sequence > *sequence
11227                });
11228            if !advanced {
11229                continue;
11230            }
11231            if is_first_frame {
11232                // Do NOT sessionize on first PTY output: a session is a
11233                // provider conversation id, not bytes on the wire. Drafts
11234                // leave the provisional map only when HQ attaches
11235                // (Resize / TerminalBytes). The attach-never-arrived timer
11236                // (PROVISIONAL_SPAWN_GRACE_MS) covers deploy lag; HQ's
11237                // disconnect grace starts only on a real client WS close.
11238                // This is the first PTY output this node has observed for
11239                // this session -- a one-time event per spawn, not per-frame
11240                // chatter (the watermark makes sure of that). A session that
11241                // reaches this line with no controller attached anywhere on
11242                // the node is running unobserved: exactly how processes from
11243                // repeated spawn attempts went orphaned for hours without a
11244                // single line about it.
11245                if self.controller_state().is_none() {
11246                    tracing::warn!(
11247                        node_id = %self.node_id,
11248                        workspace_id = %address.workspace_id,
11249                        session = ?address.session,
11250                        terminal_size = ?frame.size,
11251                        "session producing output with no controller attached",
11252                    );
11253                } else {
11254                    tracing::info!(
11255                        node_id = %self.node_id,
11256                        workspace_id = %address.workspace_id,
11257                        session = ?address.session,
11258                        terminal_size = ?frame.size,
11259                        "session produced its first output",
11260                    );
11261                }
11262            }
11263            watermarks.insert(
11264                address.session.instance_id,
11265                (address.clone(), frame.sequence),
11266            );
11267            if let Some((at, reported)) = self
11268                .pty_input_marks
11269                .lock()
11270                .unwrap_or_else(|poisoned| poisoned.into_inner())
11271                .get_mut(&address.session.instance_id)
11272            {
11273                // First frame after an input: PTY write -> output published.
11274                if !*reported && at.elapsed() < Duration::from_secs(5) {
11275                    *reported = true;
11276                    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");
11277                }
11278            }
11279            events.push(NodeEvent::TerminalFrame { address, frame });
11280        }
11281        drop(watermarks);
11282        if events.is_empty() {
11283            return;
11284        }
11285        let mut history = self
11286            .history
11287            .lock()
11288            .unwrap_or_else(|poisoned| poisoned.into_inner());
11289        let mut envelopes = Vec::with_capacity(events.len());
11290        for event in events {
11291            let sequence = history
11292                .last_sequence
11293                .checked_add(1)
11294                .expect("node event sequence exhausted");
11295            history.last_sequence = sequence;
11296            envelopes.push(NodeEventEnvelope { sequence, event });
11297        }
11298        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11299        drop(history);
11300    }
11301
11302    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11303    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11304    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11305    /// counter every other event kind shares, never a counter of its own --
11306    /// `queue_connection_event`/`queue_connection_event_batch` compare
11307    /// `envelope.sequence` against one `discard_events_through` watermark
11308    /// regardless of which broadcast channel it arrived on, so a
11309    /// per-channel counter would desynchronize that comparison.
11310    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11311        let mut history = self
11312            .history
11313            .lock()
11314            .unwrap_or_else(|poisoned| poisoned.into_inner());
11315        let sequence = history
11316            .last_sequence
11317            .checked_add(1)
11318            .expect("node event sequence exhausted");
11319        history.last_sequence = sequence;
11320        drop(history);
11321        // Debug, not info/warn -- this fires on every single chunk, but it
11322        // is exactly the counter the next drop-measurement pass needs: what
11323        // the node actually emitted (`source_sequence`, `kind`) versus what
11324        // a harness subscriber's own forwarder later logs having received
11325        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11326        // event"), so a gap between the two logs names itself instead of
11327        // needing a new counter added ad hoc.
11328        tracing::debug!(
11329            source_sequence = chunk.source_sequence,
11330            kind = agent_stream_chunk_kind_label(&chunk.kind),
11331            envelope_sequence = sequence,
11332            "publishing agent stream chunk",
11333        );
11334        let envelope = NodeEventEnvelope {
11335            sequence,
11336            event: NodeEvent::AgentStream { address, chunk },
11337        };
11338        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11339    }
11340
11341    fn current_sequence(&self) -> u64 {
11342        self.history
11343            .lock()
11344            .unwrap_or_else(|poisoned| poisoned.into_inner())
11345            .last_sequence
11346    }
11347
11348    fn controller_state(&self) -> Option<ControllerState> {
11349        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11350        let lease = controller.as_ref().copied()?;
11351        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11352        if remaining.is_zero() {
11353            *controller = None;
11354            return None;
11355        }
11356        Some(ControllerState {
11357            connection_id: lease.connection_id,
11358            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11359        })
11360    }
11361
11362    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11363        if role != ClientRole::Operator {
11364            tracing::warn!(
11365                node_id = %self.node_id,
11366                connection_id,
11367                "controller lease rejected: observer connections cannot acquire control",
11368            );
11369            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11370        }
11371        let lease_ms = if lease_ms == 0 {
11372            DEFAULT_CONTROLLER_LEASE_MS
11373        } else {
11374            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11375        };
11376        let now = Instant::now();
11377        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11378        if let Some(active) = controller.as_ref() {
11379            if active.connection_id != connection_id && active.expires_at > now {
11380                tracing::warn!(
11381                    node_id = %self.node_id,
11382                    connection_id,
11383                    holder_connection_id = active.connection_id,
11384                    "controller lease rejected: another operator holds it",
11385                );
11386                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11387            }
11388        }
11389        // A reconnect (this connection already holds the lease, renewing
11390        // before expiry) happens on a periodic cadence; logging it at INFO
11391        // every renewal would be exactly the heartbeat chatter this crate
11392        // family's level discipline forbids. A genuine attach -- the
11393        // controller identity actually changing -- is a state change and
11394        // gets INFO; a same-connection renewal gets DEBUG.
11395        let is_new_attach = match controller.as_ref() {
11396            Some(active) => active.connection_id != connection_id,
11397            None => true,
11398        };
11399        *controller = Some(ControllerLease {
11400            connection_id,
11401            expires_at: now + Duration::from_millis(lease_ms),
11402        });
11403        drop(controller);
11404        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11405        if is_new_attach {
11406            tracing::info!(
11407                node_id = %self.node_id,
11408                connection_id,
11409                lease_ms,
11410                "controller lease acquired",
11411            );
11412        } else {
11413            tracing::debug!(
11414                node_id = %self.node_id,
11415                connection_id,
11416                lease_ms,
11417                "controller lease renewed",
11418            );
11419        }
11420        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11421        Ok(state)
11422    }
11423
11424    fn release_controller(&self, connection_id: u64) -> bool {
11425        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11426        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11427        if released {
11428            *controller = None;
11429        }
11430        drop(controller);
11431        if released {
11432            tracing::info!(
11433                node_id = %self.node_id,
11434                connection_id,
11435                "controller lease released",
11436            );
11437            self.publish(NodeEvent::ControllerChanged { controller: None });
11438        }
11439        released
11440    }
11441
11442    /// Mark a fresh Spawn as provisional and arm the attach-never-arrived
11443    /// reaper (PROVISIONAL_SPAWN_GRACE_MS from spawn). Removed from this map
11444    /// only when HQ attaches (Resize / TerminalBytes) — that is *not* a
11445    /// provider session; it only cancels the orphan timer. Disconnect/reload
11446    /// draft kills are owned by HQ and arm only on a real client WS close.
11447    /// ACP path of launch options: once the agent advertises its config options, write the
11448    /// pending model / effort through session/set_config_option (session/set_model when the
11449    /// agent has no model option). Gaps are logged, never forced.
11450    fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11451        let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11452            return;
11453        };
11454        let id = address.session.instance_id;
11455        let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11456            return;
11457        };
11458        let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11459            .iter()
11460            .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11461            .collect();
11462        let (writes, set_model, effort_gap) =
11463            backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11464        if set_model {
11465            if let Some(model_id) = model.clone() {
11466                if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11467                    tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11468                }
11469            }
11470        }
11471        for (option_id, value_json) in writes {
11472            if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11473                tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11474            }
11475        }
11476        if effort_gap {
11477            tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11478        }
11479        tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11480    }
11481
11482    fn mark_spawn_provisional(&self, address: SessionAddress) {
11483        let id = address.session.instance_id;
11484        self.provisional_spawns
11485            .lock()
11486            .unwrap_or_else(|poisoned| poisoned.into_inner())
11487            .insert(id, (address, Instant::now()));
11488        tracing::info!(
11489            node_id = %self.node_id,
11490            instance_id = id.0,
11491            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11492            "draft spawn; waiting for HQ view",
11493        );
11494    }
11495
11496    fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11497        let removed = self
11498            .provisional_spawns
11499            .lock()
11500            .unwrap_or_else(|poisoned| poisoned.into_inner())
11501            .remove(&instance_id);
11502        if removed.is_some() {
11503            tracing::info!(
11504                node_id = %self.node_id,
11505                instance_id = instance_id.0,
11506                "draft spawn claimed by HQ view",
11507            );
11508        }
11509    }
11510
11511    fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11512        let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11513        let mut map = self
11514            .provisional_spawns
11515            .lock()
11516            .unwrap_or_else(|poisoned| poisoned.into_inner());
11517        let due: Vec<AgentInstanceId> = map
11518            .iter()
11519            .filter(|(_, (_, at))| at.elapsed() >= grace)
11520            .map(|(id, _)| *id)
11521            .collect();
11522        due.into_iter()
11523            .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11524            .collect()
11525    }
11526
11527    async fn reap_provisional_spawn(&self, address: SessionAddress) {
11528        let instance_id = address.session.instance_id;
11529        tracing::warn!(
11530            node_id = %self.node_id,
11531            instance_id = instance_id.0,
11532            workspace = %address.workspace_id,
11533            reason = "attach_never_arrived",
11534            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11535            "draft spawn never viewed; stopping PTY",
11536        );
11537        let _ = self.dispatch(ControlCommand::Stop {
11538            instance_id,
11539            force: true,
11540        });
11541        let _ = self.remove_session(&address).await;
11542    }
11543
11544    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11545        if role != ClientRole::Operator {
11546            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11547        }
11548        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11549        let now = Instant::now();
11550        match controller.as_ref().copied() {
11551            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11552            Some(active) if active.expires_at <= now => {
11553                *controller = None;
11554                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11555            }
11556            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11557        }
11558    }
11559
11560    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11561        if !self
11562            .workspaces
11563            .read()
11564            .unwrap_or_else(|poisoned| poisoned.into_inner())
11565            .contains_key(&address.workspace_id)
11566        {
11567            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11568        }
11569        let binding = self
11570            .session_bindings
11571            .lock()
11572            .unwrap_or_else(|poisoned| poisoned.into_inner())
11573            .get(&address.session.instance_id)
11574            .cloned()
11575            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11576        if binding.workspace_id != address.workspace_id {
11577            return Err(failure(
11578                NodeFailureCode::SessionWorkspaceMismatch,
11579                "session belongs to another workspace",
11580            ));
11581        }
11582        if binding.generation != address.session.generation {
11583            return Err(failure(
11584                NodeFailureCode::StaleGeneration,
11585                &format!(
11586                    "stale session generation: expected {}, received {}",
11587                    binding.generation.0,
11588                    address.session.generation.0,
11589                ),
11590            ));
11591        }
11592        let snapshot = self.handle.snapshot();
11593        let Some(current) = snapshot
11594            .sessions
11595            .iter()
11596            .find(|item| item.instance_id == address.session.instance_id)
11597        else {
11598            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11599        };
11600        if current.generation != address.session.generation {
11601            return Err(failure(
11602                NodeFailureCode::StaleGeneration,
11603                &format!(
11604                    "stale session generation: expected {}, received {}",
11605                    current.generation.0,
11606                    address.session.generation.0,
11607                ),
11608            ));
11609        }
11610        Ok(current.agent_id.clone())
11611    }
11612
11613    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11614        self.dispatch_envelope(self.prepare_command(command))
11615    }
11616
11617    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11618        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11619        CommandEnvelope {
11620            id: CommandId(id),
11621            command,
11622        }
11623    }
11624
11625    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11626        let command_id = envelope.id;
11627        self.handle
11628            .dispatch(envelope)
11629            .map_err(|error| match error {
11630                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11631                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11632            })?;
11633        Ok(command_id)
11634    }
11635
11636    /// Records the kernel's own reason for asynchronously refusing
11637    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
11638    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
11639    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
11640    /// first, and if the map is still at capacity after that, the single
11641    /// oldest surviving entry is evicted -- `CommandId` is monotonically
11642    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
11643    /// is always the oldest.
11644    fn record_rejected_command(
11645        &self,
11646        command_id: CommandId,
11647        message: String,
11648        unsupported_transport: bool,
11649    ) {
11650        let now = Instant::now();
11651        let mut reasons = self.rejected_command_reasons.lock()
11652            .unwrap_or_else(|poisoned| poisoned.into_inner());
11653        reasons.retain(|_, reason| reason.expires_at > now);
11654        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11655            if let Some(&oldest) = reasons.keys().next() {
11656                reasons.remove(&oldest);
11657            }
11658        }
11659        reasons.insert(
11660            command_id,
11661            RejectedCommandReason {
11662                message,
11663                unsupported_transport,
11664                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11665            },
11666        );
11667    }
11668
11669    /// Removes and returns the kernel's reason for refusing `command_id`,
11670    /// if the drive loop has recorded one and it has not expired -- see
11671    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
11672    /// wait loop calls this instead of blindly polling out its whole
11673    /// deadline when the command it is waiting on turns out to have been
11674    /// refused rather than merely slow.
11675    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11676        let now = Instant::now();
11677        let mut reasons = self.rejected_command_reasons.lock()
11678            .unwrap_or_else(|poisoned| poisoned.into_inner());
11679        match reasons.remove(&command_id) {
11680            Some(reason) if reason.expires_at > now => Some(reason),
11681            _ => None,
11682        }
11683    }
11684
11685    async fn dispatch_bounded(
11686        &self,
11687        command: ControlCommand,
11688        timeout_duration: Duration,
11689    ) -> Result<CommandId, NodeFailure> {
11690        let deadline = Instant::now() + timeout_duration;
11691        loop {
11692            match self.dispatch(command.clone()) {
11693                Ok(command_id) => return Ok(command_id),
11694                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11695                    sleep(Duration::from_millis(2)).await;
11696                }
11697                Err(error) => return Err(error),
11698            }
11699        }
11700    }
11701
11702    fn internal_session_snapshot(
11703        &self,
11704        address: &SessionAddress,
11705    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11706        self.validate_address(address)?;
11707        self.handle
11708            .snapshot()
11709            .sessions
11710            .clone()
11711            .into_iter()
11712            .find(|session| {
11713                session.instance_id == address.session.instance_id
11714                    && session.generation == address.session.generation
11715            })
11716            .ok_or_else(|| {
11717                failure(
11718                    NodeFailureCode::UnknownSession,
11719                    "session snapshot is unavailable",
11720                )
11721            })
11722    }
11723
11724    /// Confirms `address` names a live session spawned over the ACP
11725    /// transport and returns its current snapshot -- the gate every
11726    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
11727    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
11728    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
11729    /// the session's own recorded `TransportKind` does, and that lives on
11730    /// the live snapshot, not the binding.
11731    fn require_acp_session(
11732        &self,
11733        address: &SessionAddress,
11734    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11735        let snapshot = self.internal_session_snapshot(address)?;
11736        if snapshot.transport != TransportKind::Acp {
11737            return Err(failure(
11738                NodeFailureCode::UnsupportedTransport,
11739                "this verb requires a session spawned over the ACP transport",
11740            ));
11741        }
11742        Ok(snapshot)
11743    }
11744
11745    async fn dispatch_history_bounded(
11746        &self,
11747        address: &SessionAddress,
11748        command: ControlCommand,
11749        operation: HistoryOperation,
11750    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11751        self.validate_address(address)?;
11752        let started_after = self.current_sequence();
11753        let command_id = self
11754            .dispatch_bounded(
11755                command,
11756                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11757            )
11758            .await?;
11759        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11760        let mut scan_after = started_after;
11761        let mut accepted_after = None;
11762        loop {
11763            let events = self
11764                .history
11765                .lock()
11766                .unwrap_or_else(|poisoned| poisoned.into_inner())
11767                .events
11768                .iter()
11769                .filter(|envelope| envelope.sequence > scan_after)
11770                .cloned()
11771                .collect::<Vec<_>>();
11772            for envelope in events {
11773                scan_after = scan_after.max(envelope.sequence);
11774                let NodeEvent::Control {
11775                    address: event_address,
11776                    event,
11777                } = envelope.event
11778                else {
11779                    continue;
11780                };
11781                if event_address != *address {
11782                    continue;
11783                }
11784                if event.command_id == Some(command_id) {
11785                    match &event.event {
11786                        ControlEventKind::CommandRejected { .. } => {
11787                            return Err(failure(
11788                                NodeFailureCode::InvalidRequest,
11789                                "history request was rejected",
11790                            ));
11791                        }
11792                        ControlEventKind::HistoryRequested {
11793                            operation: requested,
11794                            ..
11795                        } if requested == &operation => {
11796                            accepted_after = Some(envelope.sequence);
11797                            continue;
11798                        }
11799                        _ => {}
11800                    }
11801                }
11802                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11803                    match (&operation, &event.event) {
11804                        (
11805                            HistoryOperation::Discover { .. },
11806                            ControlEventKind::HistoryDiscovered { .. },
11807                        )
11808                        | (
11809                            HistoryOperation::Load { .. },
11810                            ControlEventKind::HistoryLoaded { .. },
11811                        ) => return self.internal_session_snapshot(address),
11812                        (_, ControlEventKind::HistoryFailed { .. }) => {
11813                            return Err(failure(
11814                                NodeFailureCode::BackendOperationFailed,
11815                                "history operation failed",
11816                            ));
11817                        }
11818                        _ => {}
11819                    }
11820                }
11821            }
11822            if Instant::now() >= deadline {
11823                return Err(failure(
11824                    NodeFailureCode::BackendBusy,
11825                    if accepted_after.is_some() {
11826                        "history operation did not settle before the bounded deadline"
11827                    } else {
11828                        "history operation was not accepted before the bounded deadline"
11829                    },
11830                ));
11831            }
11832            sleep(Duration::from_millis(2)).await;
11833        }
11834    }
11835
11836    async fn discover_history(
11837        &self,
11838        address: &SessionAddress,
11839        limit: u16,
11840    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11841        let query = HistoryQuery {
11842            working_directory: Some(self.workspace_root(&address.workspace_id)?),
11843            limit,
11844        };
11845        query
11846            .validate()
11847            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11848        let snapshot = self
11849            .dispatch_history_bounded(
11850                address,
11851                ControlCommand::DiscoverHistory {
11852                    instance_id: address.session.instance_id,
11853                    query: query.clone(),
11854                },
11855                HistoryOperation::Discover { query },
11856            )
11857            .await?;
11858        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11859            return Err(failure(
11860                NodeFailureCode::BackendOperationFailed,
11861                "history discovery settled into an invalid state",
11862            ));
11863        }
11864        Ok(snapshot.history.candidates)
11865    }
11866
11867    async fn load_history(
11868        &self,
11869        address: &SessionAddress,
11870        candidate_id: String,
11871    ) -> Result<HistorySessionRecord, NodeFailure> {
11872        validate_candidate_id(&candidate_id)
11873            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11874        let snapshot = self
11875            .dispatch_history_bounded(
11876                address,
11877                ControlCommand::LoadHistory {
11878                    instance_id: address.session.instance_id,
11879                    candidate_id: candidate_id.clone(),
11880                },
11881                HistoryOperation::Load {
11882                    candidate_id: candidate_id.clone(),
11883                },
11884            )
11885            .await?;
11886        if snapshot.history.pending.is_some()
11887            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11888        {
11889            return Err(failure(
11890                NodeFailureCode::BackendOperationFailed,
11891                "history load settled into an invalid state",
11892            ));
11893        }
11894        snapshot.history.loaded.ok_or_else(|| {
11895            failure(
11896                NodeFailureCode::BackendOperationFailed,
11897                "history load produced no bounded session",
11898            )
11899        })
11900    }
11901
11902    /// `allow_clean_detachment`: `false` for the on-demand
11903    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
11904    /// managed record must stay untouched, still live, for the whole call);
11905    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
11906    /// reacts to the very clean-exit event that detaches the record out from
11907    /// under this same call — see `session_record_export_target_matches`.
11908    async fn export_context_pack_for_session_record(
11909        &self,
11910        record_id: &SessionRecordId,
11911        session: &SessionAddress,
11912        allow_clean_detachment: bool,
11913    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11914        // C2 replaces a relayed failure's prose with a canned per-code
11915        // string (`C2NodeFailure`'s own `From`), so this path's dozen
11916        // distinct refusals are indistinguishable to anyone downstream --
11917        // and a refusal here expires a continuation, which keeps no field
11918        // for why. The prose stays on the node, where it was written.
11919        let outcome = self
11920            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11921            .await;
11922        if let Err(failure) = &outcome {
11923            tracing::warn!(
11924                record = %record_id,
11925                code = ?failure.code,
11926                reason = %failure.message,
11927                "session-record context-pack export refused",
11928            );
11929        }
11930        outcome
11931    }
11932
11933    async fn export_context_pack_for_session_record_inner(
11934        &self,
11935        record_id: &SessionRecordId,
11936        session: &SessionAddress,
11937        allow_clean_detachment: bool,
11938    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11939        // Several steps here refuse with the same BackendOperationFailed
11940        // code and no prose of their own, so which step failed is
11941        // otherwise unrecoverable -- and a refusal here expires a
11942        // continuation.
11943        let step = |name: &'static str| move |failure: NodeFailure| {
11944            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11945            failure
11946        };
11947        let record = self.record(record_id).map_err(step("record-lookup"))?;
11948        // `provider_session` is `Some` only for a provider whose resolved
11949        // runtime policy admits live identity verification (raw PTY plus a
11950        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
11951        // The PTY-sidecar-observation shape (no fleet provider today, but
11952        // capability-keyed rather than vendor-locked) hardcodes
11953        // `provider_session_identity: false` in `provider_runtime::evaluate`,
11954        // so its records go straight to `Live` with `provider_session: None`
11955        // and stay that way for the record's entire life -- that is this
11956        // shape's permanent resting state, not a race with observation, and
11957        // `discover_history`/`load_history` below do not need an identity to
11958        // do their job for it. `IdentityPending` is the actual race (an
11959        // identity-admitted provider whose observation has not landed yet):
11960        // it keeps refusing here rather than falling back, because that
11961        // provider's own history the fallback would otherwise trust is not
11962        // guaranteed to be exact without the identity it is still waiting on.
11963        let identity = match record.provider_session.clone() {
11964            Some(identity) => {
11965                if identity.key != ProviderSessionKey::SessionId {
11966                    return Err(step("provider-identity-key")(failure(
11967                        NodeFailureCode::BackendOperationFailed,
11968                        "managed session provider identity cannot bind history exactly",
11969                    )));
11970                }
11971                Some(identity)
11972            }
11973            None if record.state == ManagedSessionState::IdentityPending => {
11974                return Err(step("provider-session-identity")(failure(
11975                    NodeFailureCode::BackendOperationFailed,
11976                    "managed session has not yet observed its provider session identity",
11977                )));
11978            }
11979            None => None,
11980        };
11981        self.revalidate_session_record_context_export(
11982            &record,
11983            identity.as_ref(),
11984            session,
11985            allow_clean_detachment,
11986        ).map_err(step("revalidate-before-discovery"))?;
11987        let candidates = self
11988            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11989            .await
11990            .map_err(step("discover-history"))?;
11991        // With no verified identity to filter by, the discovered set must
11992        // still resolve to exactly one candidate: the same ambiguity refusal
11993        // an identity filter would otherwise produce is the only safety
11994        // property left available for this shape.
11995        let candidate_id = select_history_candidate(
11996            &candidates,
11997            identity.as_ref().map(|identity| identity.id.as_str()),
11998        ).map_err(step("unique-candidate"))?;
11999        let loaded = self.load_history(session, candidate_id.clone()).await
12000            .map_err(step("load-history"))?;
12001        if let Some(identity) = identity.as_ref() {
12002            if loaded.session_id != identity.id {
12003                return Err(failure(
12004                    NodeFailureCode::SessionRecordConflict,
12005                    "loaded history identity does not match the managed session record",
12006                ));
12007            }
12008        }
12009        self.revalidate_session_record_context_export(
12010            &record,
12011            identity.as_ref(),
12012            session,
12013            allow_clean_detachment,
12014        )?;
12015        self.revalidate_loaded_history(session, &candidate_id, &loaded)
12016            .map_err(step("revalidate-loaded-history"))?;
12017        let pack = self.materialize_context_pack(session).await
12018            .map_err(step("materialize-context-pack"))?;
12019        self.commit_context_pack_for_session_record(
12020            &record,
12021            identity.as_ref(),
12022            session,
12023            &candidate_id,
12024            &loaded,
12025            pack,
12026            allow_clean_detachment,
12027        )
12028    }
12029
12030    async fn export_context_pack(
12031        &self,
12032        address: &SessionAddress,
12033    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12034        let pack = self.materialize_context_pack(address).await?;
12035        self.commit_context_pack(pack)
12036    }
12037
12038    async fn materialize_context_pack(
12039        &self,
12040        address: &SessionAddress,
12041    ) -> Result<NodeContextPack, NodeFailure> {
12042        let snapshot = self.internal_session_snapshot(address)?;
12043        if !context_pack_source_status_is_usable(&snapshot.status) {
12044            return Err(failure(
12045                NodeFailureCode::InvalidRequest,
12046                "context export requires a running or successfully exited source session",
12047            ));
12048        }
12049        if snapshot.history.pending.is_some()
12050            || snapshot.history.loaded_candidate_id.is_none()
12051        {
12052            return Err(failure(
12053                NodeFailureCode::InvalidRequest,
12054                "context export requires settled loaded history",
12055            ));
12056        }
12057        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12058            failure(
12059                NodeFailureCode::InvalidRequest,
12060                "context export requires settled loaded history",
12061            )
12062        })?;
12063        let source_provider = snapshot.agent_id.clone();
12064        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12065        let repository = self
12066            .context_pack_repository(&address.workspace_id)
12067            .await?;
12068        let current = self.internal_session_snapshot(address)?;
12069        if !context_pack_source_status_is_usable(&current.status)
12070            || current.agent_id != source_provider
12071            || current.history.pending.is_some()
12072            || current.history.loaded_candidate_id != loaded_candidate_id
12073            || current.history.loaded.as_ref() != Some(&loaded)
12074        {
12075            return Err(failure(
12076                NodeFailureCode::ContextPackMaterializationFailed,
12077                "context source changed during bounded repository capture",
12078            ));
12079        }
12080        NodeContextPack::export_with_repository(
12081            ContextPackLineageReceipt {
12082                source_node_id: self.node_id.clone(),
12083                source_session: address.clone(),
12084                source_provider,
12085            },
12086            &loaded,
12087            Some(repository),
12088        )
12089        .map_err(|_| {
12090            failure(
12091                NodeFailureCode::ContextPackMaterializationFailed,
12092                "bounded context pack export failed",
12093            )
12094        })
12095    }
12096
12097    /// Write-through to the durable [`ContextPackStore`], if one is
12098    /// configured (`state_path` is `Some`). A node with no durable state
12099    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
12100    /// open at all and this is a no-op, matching `persist_state`'s own
12101    /// "durability optional, degrade gracefully when unconfigured" idiom.
12102    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12103        let mut store = self
12104            .context_pack_store
12105            .lock()
12106            .unwrap_or_else(|poisoned| poisoned.into_inner());
12107        let Some(store) = store.as_mut() else {
12108            return Ok(());
12109        };
12110        store.commit(pack).map_err(|_| {
12111            failure(
12112                NodeFailureCode::ContextPackMaterializationFailed,
12113                "durable context pack commit failed",
12114            )
12115        })
12116    }
12117
12118    fn commit_context_pack(
12119        &self,
12120        pack: NodeContextPack,
12121    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12122        self.commit_context_pack_durably(&pack)?;
12123        self.context_catalog
12124            .write()
12125            .unwrap_or_else(|poisoned| poisoned.into_inner())
12126            .insert(pack)
12127            .map_err(|_| {
12128                failure(
12129                    NodeFailureCode::ContextPackMaterializationFailed,
12130                    "context pack catalog is full",
12131                )
12132            })
12133    }
12134
12135    fn commit_context_pack_for_session_record(
12136        &self,
12137        expected: &ManagedSessionRecord,
12138        identity: Option<&ProviderSessionIdentity>,
12139        session: &SessionAddress,
12140        candidate_id: &str,
12141        loaded: &HistorySessionRecord,
12142        pack: NodeContextPack,
12143        allow_clean_detachment: bool,
12144    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12145        let current_root = self.workspace_root(&expected.workspace_id)?;
12146        let _transaction = self
12147            .state_transaction
12148            .lock()
12149            .unwrap_or_else(|poisoned| poisoned.into_inner());
12150        let records = self
12151            .session_records
12152            .lock()
12153            .unwrap_or_else(|poisoned| poisoned.into_inner());
12154        let current = records.records.get(&expected.record_id).ok_or_else(|| {
12155            failure(
12156                NodeFailureCode::SessionRecordConflict,
12157                "managed session record disappeared during context export",
12158            )
12159        })?;
12160        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12161            return Err(failure(
12162                NodeFailureCode::SessionRecordConflict,
12163                "managed session record changed during context export",
12164            ));
12165        }
12166        let bindings = self
12167            .session_bindings
12168            .lock()
12169            .unwrap_or_else(|poisoned| poisoned.into_inner());
12170        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12171            failure(
12172                NodeFailureCode::SessionRecordConflict,
12173                "managed session runtime binding disappeared during context export",
12174            )
12175        })?;
12176        let runtime = self
12177            .handle
12178            .snapshot()
12179            .sessions
12180            .iter()
12181            .find(|snapshot| {
12182                snapshot.instance_id == session.session.instance_id
12183                    && snapshot.generation == session.session.generation
12184            })
12185            .cloned()
12186            .ok_or_else(|| {
12187                failure(
12188                    NodeFailureCode::SessionRecordConflict,
12189                    "managed session runtime disappeared during context export",
12190                )
12191            })?;
12192        let receipt = pack.receipt();
12193        if !session_record_context_export_binding_is_exact(
12194            expected,
12195            current,
12196            identity,
12197            session,
12198            binding,
12199            &runtime.agent_id,
12200            &current_root,
12201            allow_clean_detachment,
12202        ) || !session_record_context_export_source_is_usable(
12203            current,
12204            &runtime.status,
12205            allow_clean_detachment,
12206        ) || runtime.history.pending.is_some()
12207            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12208            || runtime.history.loaded.as_ref() != Some(loaded)
12209            || receipt.lineage.source_node_id != self.node_id
12210            || receipt.lineage.source_session != *session
12211            || receipt.lineage.source_provider != current.provider
12212            || receipt.source_message_count != loaded.message_count
12213        {
12214            return Err(failure(
12215                NodeFailureCode::SessionRecordConflict,
12216                "managed session source changed before context catalog commit",
12217            ));
12218        }
12219        self.commit_context_pack_durably(&pack)?;
12220        self.context_catalog
12221            .write()
12222            .unwrap_or_else(|poisoned| poisoned.into_inner())
12223            .insert(pack)
12224            .map_err(|_| {
12225                failure(
12226                    NodeFailureCode::ContextPackMaterializationFailed,
12227                    "context pack catalog is full",
12228                )
12229            })
12230    }
12231
12232    async fn context_pack_repository(
12233        &self,
12234        workspace_id: &WorkspaceId,
12235    ) -> Result<ContextPackRepository, NodeFailure> {
12236        let first = self
12237            .context_pack_repository_observation(workspace_id)
12238            .await?;
12239        let second = self
12240            .context_pack_repository_observation(workspace_id)
12241            .await?;
12242        stable_context_pack_repository(first, second)
12243    }
12244
12245    async fn context_pack_repository_observation(
12246        &self,
12247        workspace_id: &WorkspaceId,
12248    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12249        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12250        let head = if git.is_repository {
12251            let root = self.workspace_root(workspace_id)?;
12252            match resolve_base_commit_with_timeout(
12253                &root,
12254                "HEAD",
12255                GIT_COMMAND_TIMEOUT_MS,
12256            )
12257            .await
12258            {
12259                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12260                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12261                    ContextPackRepositoryHead::Unborn
12262                }
12263                Err(_) => {
12264                    return Err(failure(
12265                        NodeFailureCode::ContextPackMaterializationFailed,
12266                        "context pack HEAD observation failed",
12267                    ));
12268                }
12269            }
12270        } else {
12271            ContextPackRepositoryHead::NotRepository
12272        };
12273        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12274        for selected_path in CONTEXT_PACK_SELECTED_FILES {
12275            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12276                failure(
12277                    NodeFailureCode::InvalidRepositoryPath,
12278                    "context pack selected file path is invalid",
12279                )
12280            })?;
12281            match self.read_workspace_file(workspace_id.clone(), path).await {
12282                Ok(WorkspaceFileRead {
12283                    content: WorkspaceFileContent::Utf8 { text, byte_len },
12284                    ..
12285                }) => files.push(ContextPackRepositoryFileSource::utf8(
12286                    selected_path,
12287                    text,
12288                    byte_len,
12289                )),
12290                Ok(WorkspaceFileRead {
12291                    content: WorkspaceFileContent::NonUtf8 { byte_len },
12292                    ..
12293                }) => files.push(ContextPackRepositoryFileSource::skipped(
12294                    selected_path,
12295                    ContextPackSelectedFileSkipReason::NonUtf8,
12296                    Some(byte_len),
12297                )),
12298                Ok(WorkspaceFileRead {
12299                    content: WorkspaceFileContent::TooLarge { .. },
12300                    ..
12301                }) => files.push(ContextPackRepositoryFileSource::skipped(
12302                    selected_path,
12303                    ContextPackSelectedFileSkipReason::TooLarge,
12304                    None,
12305                )),
12306                Err(error) => {
12307                    let reason = if matches!(
12308                        error.code,
12309                        NodeFailureCode::InvalidRepositoryPath
12310                            | NodeFailureCode::RepositoryFileNotRegular
12311                            | NodeFailureCode::RepositoryPathUnsafe
12312                    ) {
12313                        ContextPackSelectedFileSkipReason::Unsafe
12314                    } else {
12315                        ContextPackSelectedFileSkipReason::Unavailable
12316                    };
12317                    files.push(ContextPackRepositoryFileSource::skipped(
12318                        selected_path,
12319                        reason,
12320                        None,
12321                    ));
12322                }
12323            }
12324        }
12325        Ok(ContextPackRepositoryObservation { head, git, files })
12326    }
12327
12328    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12329        let referenced_by_binding = self
12330            .session_bindings
12331            .lock()
12332            .unwrap_or_else(|poisoned| poisoned.into_inner())
12333            .values()
12334            .any(|binding| {
12335                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12336            });
12337        let referenced_by_record = self
12338            .session_records
12339            .lock()
12340            .unwrap_or_else(|poisoned| poisoned.into_inner())
12341            .records
12342            .values()
12343            .any(|record| record.context_id.as_ref() == Some(context_id));
12344        let referenced_by_materialization = self
12345            .materializations
12346            .lock()
12347            .unwrap_or_else(|poisoned| poisoned.into_inner())
12348            .values()
12349            .any(|ownership| {
12350                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12351            });
12352        if referenced_by_binding
12353            || referenced_by_record
12354            || referenced_by_materialization
12355        {
12356            return Err(failure(
12357                NodeFailureCode::ContextPackBusy,
12358                "context pack is still referenced",
12359            ));
12360        }
12361        let mut catalog = self.context_catalog
12362            .write()
12363            .unwrap_or_else(|poisoned| poisoned.into_inner());
12364        if catalog.get(context_id).is_none() {
12365            return Err(failure(
12366                NodeFailureCode::UnknownContextPack,
12367                "context pack is unavailable",
12368            ));
12369        }
12370        self.spawn_idempotency
12371            .lock()
12372            .unwrap_or_else(|poisoned| poisoned.into_inner())
12373            .entries
12374            .retain(|_, entry| !entry.value.references_context(context_id));
12375        let removed = catalog.remove(context_id);
12376        debug_assert!(removed.is_some(), "validated context pack remains present");
12377        Ok(())
12378    }
12379
12380    async fn dispatch_input_bounded(
12381        &self,
12382        address: &SessionAddress,
12383        action: InputAction,
12384    ) -> Result<(), NodeFailure> {
12385        let started_after = self.current_sequence();
12386        self.pty_input_marks
12387            .lock()
12388            .unwrap_or_else(|poisoned| poisoned.into_inner())
12389            .insert(address.session.instance_id, (Instant::now(), false));
12390        self.pty_input_wake.notify_one();
12391        let command_id = self
12392            .dispatch_bounded(
12393                ControlCommand::SendInput {
12394                    instance_id: address.session.instance_id,
12395                    action,
12396                },
12397                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12398            )
12399            .await?;
12400        // A semantic prompt may spend the provider's full readiness budget before
12401        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12402        // but strictly beyond every readiness policy in this node's catalog.
12403        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12404        let mut scan_after = started_after;
12405        let mut accepted_after = None;
12406        loop {
12407            let events = self
12408                .history
12409                .lock()
12410                .unwrap_or_else(|poisoned| poisoned.into_inner())
12411                .events
12412                .iter()
12413                .filter(|envelope| envelope.sequence > scan_after)
12414                .cloned()
12415                .collect::<Vec<_>>();
12416            for envelope in events {
12417                scan_after = scan_after.max(envelope.sequence);
12418                let NodeEvent::Control {
12419                    address: event_address,
12420                    event,
12421                } = envelope.event
12422                else {
12423                    continue;
12424                };
12425                if event_address != *address {
12426                    continue;
12427                }
12428                if event.command_id == Some(command_id) {
12429                    match &event.event {
12430                        ControlEventKind::CommandRejected { message } => {
12431                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12432                        }
12433                        ControlEventKind::InputRequested { .. } => {
12434                            accepted_after = Some(envelope.sequence);
12435                            continue;
12436                        }
12437                        _ => {}
12438                    }
12439                }
12440                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12441                    match &event.event {
12442                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12443                        ControlEventKind::InputFailed { message, .. } => {
12444                            return Err(failure(
12445                                NodeFailureCode::BackendOperationFailed,
12446                                message,
12447                            ));
12448                        }
12449                        _ => {}
12450                    }
12451                }
12452            }
12453            if Instant::now() >= deadline {
12454                return Err(failure(
12455                    NodeFailureCode::BackendBusy,
12456                    if accepted_after.is_some() {
12457                        "input was accepted but did not settle before the bounded deadline"
12458                    } else {
12459                        "input was not accepted before the bounded deadline"
12460                    },
12461                ));
12462            }
12463            sleep(Duration::from_millis(2)).await;
12464        }
12465    }
12466
12467    async fn dispatch_resize_bounded(
12468        &self,
12469        address: &SessionAddress,
12470        size: gate4agent_types::TerminalSize,
12471    ) -> Result<(), NodeFailure> {
12472        let started_after = self.current_sequence();
12473        let command_id = self
12474            .dispatch_bounded(
12475                ControlCommand::Resize {
12476                    instance_id: address.session.instance_id,
12477                    size,
12478                },
12479                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12480            )
12481            .await?;
12482        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12483        let mut scan_after = started_after;
12484        let mut accepted_after = None;
12485        loop {
12486            let events = self
12487                .history
12488                .lock()
12489                .unwrap_or_else(|poisoned| poisoned.into_inner())
12490                .events
12491                .iter()
12492                .filter(|envelope| envelope.sequence > scan_after)
12493                .cloned()
12494                .collect::<Vec<_>>();
12495            for envelope in events {
12496                scan_after = scan_after.max(envelope.sequence);
12497                let NodeEvent::Control {
12498                    address: event_address,
12499                    event,
12500                } = envelope.event
12501                else {
12502                    continue;
12503                };
12504                if event_address != *address {
12505                    continue;
12506                }
12507                if event.command_id == Some(command_id) {
12508                    match &event.event {
12509                        ControlEventKind::CommandRejected { message } => {
12510                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12511                        }
12512                        ControlEventKind::ResizeRequested {
12513                            size: requested_size,
12514                            ..
12515                        } if *requested_size == size => {
12516                            accepted_after = Some(envelope.sequence);
12517                            continue;
12518                        }
12519                        _ => {}
12520                    }
12521                }
12522                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12523                    match &event.event {
12524                        ControlEventKind::Resized { size: settled_size }
12525                            if *settled_size == size =>
12526                        {
12527                            return Ok(());
12528                        }
12529                        ControlEventKind::ResizeFailed { message } => {
12530                            return Err(failure(
12531                                NodeFailureCode::BackendOperationFailed,
12532                                message,
12533                            ));
12534                        }
12535                        _ => {}
12536                    }
12537                }
12538            }
12539            if Instant::now() >= deadline {
12540                return Err(failure(
12541                    NodeFailureCode::BackendBusy,
12542                    if accepted_after.is_some() {
12543                        "resize was accepted but did not settle before the bounded deadline"
12544                    } else {
12545                        "resize was not accepted before the bounded deadline"
12546                    },
12547                ));
12548            }
12549            sleep(Duration::from_millis(2)).await;
12550        }
12551    }
12552
12553    async fn spawn_session(
12554        &self,
12555        workspace_id: WorkspaceId,
12556        provider: AgentId,
12557        mode: SessionMode,
12558        terminal_size: gate4agent_types::TerminalSize,
12559        initial_prompt: Option<String>,
12560    ) -> Result<SessionAddress, NodeFailure> {
12561        if self.managed_worktrees.lock()
12562            .unwrap_or_else(|poisoned| poisoned.into_inner())
12563            .lease_for_workspace(&workspace_id)
12564            .is_some()
12565        {
12566            return Err(failure(
12567                NodeFailureCode::ManagedWorktreeOwnershipConflict,
12568                "managed worktree targets may only be spawned through their lease request",
12569            ));
12570        }
12571        self
12572            .spawn_session_with_deadline(
12573                workspace_id,
12574                provider,
12575                mode,
12576                terminal_size,
12577                initial_prompt,
12578                None,
12579                None,
12580                None,
12581                None,
12582                None,
12583                SpawnRecordPolicy::ProviderIdentityOnly,
12584                None,
12585                &[],
12586                None,
12587                // `NodeRequest::Spawn` (this method's only caller) carries no
12588                // approval-level selection of its own -- see
12589                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
12590                // the one operator-wire path that can choose something other
12591                // than the axis default.
12592                ApprovalLevel::default(),
12593                None,
12594                None,
12595            )
12596            .await
12597            .map(|(session, _runtime_policy)| session)
12598    }
12599
12600    async fn spawn_session_with_deadline(
12601        &self,
12602        workspace_id: WorkspaceId,
12603        provider: AgentId,
12604        mode: SessionMode,
12605        terminal_size: gate4agent_types::TerminalSize,
12606        initial_prompt: Option<String>,
12607        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12608        bundle: Option<ResolvedBundleReceipt>,
12609        context: Option<ResolvedContextPackReceipt>,
12610        managed_authority: Option<ManagedWorktreeLeaseId>,
12611        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12612        record_policy: SpawnRecordPolicy,
12613        deadline: Option<Instant>,
12614        required_capabilities: &[ProviderRuntimeCapability],
12615        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12616        approval_level: ApprovalLevel,
12617        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12618        // Opaque dig2browser station profile id (ids only). Feature
12619        // `dig2-station-probe`: after probe at resolve, spawn takes an
12620        // exclusive node-local lease; cleared on session end. Never
12621        // ImportSession / cookies on C2. Feature off: ignored (stub echo
12622        // already happened on the receipt path).
12623        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12624    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12625        let reserved_lease = self.managed_worktrees.lock()
12626            .unwrap_or_else(|poisoned| poisoned.into_inner())
12627            .lease_for_workspace(&workspace_id);
12628        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12629            (Some(expected), Some(provided)) if expected == provided => {}
12630            (None, None) => {}
12631            _ => {
12632                return Err(failure(
12633                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
12634                    "spawn lacks exact managed worktree lease authority",
12635                ));
12636            }
12637        }
12638        // Refuse concurrent distinct environment profiles (incl. ProviderHome
12639        // bindings) on the same workspace cwd. Parallel path: managed worktree
12640        // spawn so each profile gets its own workspace_id.
12641        self.ensure_workspace_environment_profile_compatible(
12642            &workspace_id,
12643            environment_profile.as_ref(),
12644        )?;
12645        let runtime_requirement = match (mode, initial_prompt.is_some()) {
12646            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12647            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12648            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12649            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12650        };
12651        // This session's own transport -- computed once, here, from `mode`,
12652        // and reused both to admit spawn (below) and, further down, to
12653        // compute the final `runtime_policy` this session actually operates
12654        // under (`provider_runtime::policy_for_transport`). Registration
12655        // reuses this exact value rather than recomputing it a second time.
12656        let transport = match mode {
12657            SessionMode::Pty => TransportKind::Pty,
12658            SessionMode::Inline => TransportKind::Pipe,
12659            SessionMode::Acp => TransportKind::Acp,
12660        };
12661        let pipe_transport = self.provider_supports_pipe_transport(&provider);
12662        let acp_transport = self.provider_supports_acp_transport(&provider);
12663        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12664            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12665                NodeFailureCode::UnsupportedSpawnCapability,
12666                "pre-admitted provider runtime no longer satisfies spawn requirements",
12667            ))?;
12668            runtime_policy
12669        } else if let Some(deadline) = deadline {
12670            let remaining = spawn_deadline_remaining(deadline)?;
12671            timeout(
12672                remaining,
12673                self.admit_provider_runtime(&provider, runtime_requirement),
12674            )
12675            .await
12676            .map_err(|_| {
12677                failure(
12678                    NodeFailureCode::SpawnDeadlineExceeded,
12679                    "provider admission exceeded the spawn deadline",
12680                )
12681            })??
12682        } else {
12683            self.admit_provider_runtime(&provider, runtime_requirement).await?
12684        };
12685        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12686        // The single point where this session's `runtime_policy` becomes
12687        // transport-correct: `admit_provider_runtime` (and the pre-admitted
12688        // branch above) only ever compute a PTY-terminal-verification-shaped
12689        // value, sound outright only for `TransportKind::Pty` (Pipe still
12690        // relies on that same value downstream -- see `policy_for_transport`'s
12691        // own doc for why). `policy_for_transport` is the explicit,
12692        // exhaustive match Item 1 asks for -- this is the ONE place its
12693        // output becomes the session's `runtime_policy`, so every later
12694        // reader (`ControlCommand::Start`, `session_bindings`, and every
12695        // engine gate that reads `state.runtime_policy`) sees the same,
12696        // correctly-computed value.
12697        let runtime_policy =
12698            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12699        if required_capabilities
12700            .iter()
12701            .any(|capability| !runtime_policy.admits(*capability))
12702        {
12703            return Err(failure(
12704                NodeFailureCode::UnsupportedSpawnCapability,
12705                "provider runtime does not admit a required spawn capability",
12706            ));
12707        }
12708        if let Some(deadline) = deadline {
12709            spawn_deadline_remaining(deadline)?;
12710        }
12711        self.ensure_binding_capacity()?;
12712        let working_directory = self.workspace_root(&workspace_id)?;
12713        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12714        let session = SessionKey {
12715            instance_id,
12716            generation: SessionGeneration(1),
12717        };
12718        let address = SessionAddress {
12719            workspace_id: workspace_id.clone(),
12720            session,
12721        };
12722        // This is the request-accepted checkpoint: admission (managed
12723        // worktree authority, runtime capability, dispatch deadline) already
12724        // passed and a concrete session identity is minted. It is a distinct
12725        // state from the process actually running (`ControlEventKind::Running`,
12726        // logged in `publish_control`) or the terminal outcome (`Exited`/
12727        // `Failed`, logged there too) -- this line exists so that gap is no
12728        // longer silent.
12729        tracing::info!(
12730            node_id = %self.node_id,
12731            workspace_id = %workspace_id,
12732            provider = %provider,
12733            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12734            session = ?address.session,
12735            "session spawn request accepted",
12736        );
12737        let mut prepared_spawn = SpawnPreparer::prepare(
12738            self,
12739            SpawnPrepareRequest {
12740                instance_id,
12741                address: &address,
12742                provider: &provider,
12743                mode,
12744                environment_profile: environment_profile.as_ref(),
12745                bundle: bundle.as_ref(),
12746                context: context.as_ref(),
12747                managed_authority: managed_authority.as_ref(),
12748                harness_mcp,
12749                approval_level,
12750                network_allowlist: network_allowlist.as_ref(),
12751                browser_profile_id,
12752            },
12753        )?;
12754        let materialization_id = prepared_spawn.materialization_id.take();
12755        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12756        let record_id = self.bind_spawn_session_with_materialization(
12757            &address,
12758            provider.clone(),
12759            mode,
12760            runtime_policy,
12761            record_policy,
12762            environment_profile,
12763            bundle,
12764            context,
12765            materialization_id,
12766        )?;
12767        prepared_spawn.retain_session_guards();
12768        // `transport` was already computed above, alongside this session's
12769        // `runtime_policy`; reused here for `Register` rather than derived a
12770        // second time.
12771        let agent_id = provider;
12772        let register_command_id = match self.dispatch_bounded(
12773            ControlCommand::Register {
12774                instance_id,
12775                agent_id,
12776                transport,
12777            },
12778            dispatch_timeout,
12779        )
12780        .await {
12781            Ok(command_id) => command_id,
12782            Err(error) => {
12783                if let Some(binding) = self.remove_binding(&address) {
12784                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12785                }
12786                if let Some(record_id) = record_id.as_ref() {
12787                    self.discard_record(record_id)?;
12788                }
12789                return Err(spawn_dispatch_error(error, deadline));
12790            }
12791        };
12792        let start_timeout = match spawn_dispatch_timeout(deadline) {
12793            Ok(timeout) => timeout,
12794            Err(error) => {
12795                let recovery = self
12796                    .rollback_spawn(
12797                        &address,
12798                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12799                    )
12800                    .await;
12801                return match recovery {
12802                    Ok(()) => {
12803                        if let Some(record_id) = record_id.as_ref() {
12804                            self.discard_record(record_id)?;
12805                        }
12806                        Err(error)
12807                    }
12808                    Err(recovery_error) => {
12809                        if let Some(record_id) = record_id.as_ref() {
12810                            self.mark_record_error(
12811                                record_id,
12812                                "spawn deadline elapsed and registration recovery failed",
12813                            )?;
12814                        }
12815                        Err(recovery_error)
12816                    }
12817                };
12818            }
12819        };
12820        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12821            if let Err(recovery_error) = self
12822                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12823                .await
12824            {
12825                return Err(recovery_error);
12826            }
12827            if let Some(record_id) = record_id.as_ref() {
12828                self.discard_record(record_id)?;
12829            }
12830            return Err(start_error);
12831        }
12832        let start_result = self
12833            .dispatch_bounded(
12834                ControlCommand::Start {
12835                    instance_id,
12836                    runtime_policy,
12837                    request: StartRequest {
12838                        working_directory,
12839                        terminal_size,
12840                        initial_prompt,
12841                        session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
12842                        approval_level,
12843                    },
12844                },
12845                start_timeout,
12846            )
12847            .await;
12848        let start_command_id = match start_result {
12849            Ok(command_id) => command_id,
12850            Err(start_error) => {
12851                let start_error = spawn_dispatch_error(start_error, deadline);
12852                let recovery = self
12853                    .rollback_spawn(
12854                        &address,
12855                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12856                    )
12857                    .await;
12858                return match recovery {
12859                    Ok(()) => {
12860                        if let Some(record_id) = record_id.as_ref() {
12861                            self.discard_record(record_id)?;
12862                        }
12863                        Err(start_error)
12864                    }
12865                    Err(recovery_error) => {
12866                        let diagnostic = format!(
12867                            "start failed and registration recovery failed: {}",
12868                            recovery_error.message,
12869                        );
12870                        if let Some(record_id) = record_id.as_ref() {
12871                            self.mark_record_error(record_id, &diagnostic)?;
12872                        }
12873                        Err(failure(recovery_error.code, &diagnostic))
12874                    }
12875                };
12876            }
12877        };
12878
12879        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12880        loop {
12881            // `Register`/`Start` only fail synchronously for a port-level
12882            // ingress problem (see `dispatch_bounded`); the kernel refusing
12883            // either command's content -- an unsupported transport, an
12884            // unknown agent -- lands one drive-loop tick later, and no
12885            // session ever appears in `self.handle.snapshot()` for it to
12886            // notice below. Check for that named reason before falling
12887            // through to the blind presence poll, so a rejected spawn
12888            // reports why instead of just timing out.
12889            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12890                .or_else(|| self.take_rejected_command_reason(start_command_id))
12891            {
12892                self.rollback_spawn(
12893                    &address,
12894                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12895                ).await.map_err(|error| {
12896                        failure(
12897                            error.code,
12898                            &format!(
12899                                "spawn was rejected ({}) and registration recovery failed: {}",
12900                                reason.message, error.message
12901                            ),
12902                        )
12903                    })?;
12904                if let Some(record_id) = record_id.as_ref() {
12905                    self.discard_record(record_id)?;
12906                }
12907                let code = if reason.unsupported_transport {
12908                    NodeFailureCode::UnsupportedTransport
12909                } else {
12910                    NodeFailureCode::BackendOperationFailed
12911                };
12912                return Err(failure(code, &reason.message));
12913            }
12914            if let Some(current) = self
12915                .handle
12916                .snapshot()
12917                .sessions
12918                .iter()
12919                .find(|current| current.instance_id == instance_id)
12920            {
12921                if current.generation != session.generation {
12922                    let divergence = failure(
12923                        NodeFailureCode::StaleGeneration,
12924                        "spawn generation diverged",
12925                    );
12926                    return match self
12927                        .rollback_spawn(
12928                            &address,
12929                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12930                        )
12931                        .await
12932                    {
12933                        Ok(()) => {
12934                            if let Some(record_id) = record_id.as_ref() {
12935                                self.discard_record(record_id)?;
12936                            }
12937                            Err(divergence)
12938                        }
12939                        Err(recovery_error) => {
12940                            if let Some(record_id) = record_id.as_ref() {
12941                                self.mark_record_error(
12942                                    record_id,
12943                                    "spawn generation diverged and recovery failed",
12944                                )?;
12945                            }
12946                            Err(recovery_error)
12947                        }
12948                    };
12949                }
12950                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12951                    prepared_spawn.retain_launch_guards();
12952                    return Ok((address, runtime_policy));
12953                }
12954            }
12955            if Instant::now() >= commit_deadline {
12956                self.rollback_spawn(
12957                    &address,
12958                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12959                ).await.map_err(|error| {
12960                        failure(
12961                            error.code,
12962                            &format!(
12963                                "start did not commit and registration recovery failed: {}",
12964                                error.message
12965                            ),
12966                        )
12967                    })?;
12968                if let Some(record_id) = record_id.as_ref() {
12969                    self.discard_record(record_id)?;
12970                }
12971                return Err(failure(
12972                    if deadline.is_some() {
12973                        NodeFailureCode::SpawnDeadlineExceeded
12974                    } else {
12975                        NodeFailureCode::BackendBusy
12976                    },
12977                    "start did not commit before the bounded deadline; registration was removed",
12978                ));
12979            }
12980            sleep(Duration::from_millis(2)).await;
12981        }
12982    }
12983
12984    fn effective_spawn_runtime_policy(
12985        &self,
12986        runtime_policy: ProviderRuntimePolicy,
12987    ) -> ProviderRuntimePolicy {
12988        #[cfg(feature = "fixture")]
12989        let runtime_policy = if self.fixture_semantic_hook_policy {
12990            ProviderRuntimePolicy::new(true, true, true, true, false, true)
12991                .expect("monitoring Hook fixture policy is internally valid")
12992        } else {
12993            runtime_policy
12994        };
12995        runtime_policy
12996    }
12997
12998    fn verify_harness_mcp_before_start(
12999        &self,
13000        prepared: Option<&PreparedHarnessMcpSpawn>,
13001    ) -> Result<(), NodeFailure> {
13002        prepared
13003            .map(PreparedHarnessMcpSpawn::verify_helper_program)
13004            .transpose()
13005            .map(|_| ())
13006            .map_err(harness_mcp_failure)
13007    }
13008
13009    async fn rollback_spawn(
13010        &self,
13011        address: &SessionAddress,
13012        commit_timeout: Duration,
13013    ) -> Result<(), NodeFailure> {
13014        self.dispatch_bounded(
13015            ControlCommand::Remove {
13016                instance_id: address.session.instance_id,
13017            },
13018            commit_timeout,
13019        )
13020        .await?;
13021        self.wait_until_removed(address, commit_timeout).await?;
13022        if let Some(binding) = self.remove_binding(address) {
13023            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13024        }
13025        Ok(())
13026    }
13027
13028    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13029        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13030        self.dispatch_bounded(
13031            ControlCommand::Remove {
13032                instance_id: address.session.instance_id,
13033            },
13034            commit_timeout,
13035        )
13036            .await?;
13037        self.wait_until_removed(address, commit_timeout).await?;
13038        let removed = self.remove_binding(address);
13039        if let Some(binding) = removed.as_ref() {
13040            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13041        }
13042        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13043            let snapshot = self.managed_worktrees.lock()
13044                .unwrap_or_else(|poisoned| poisoned.into_inner())
13045                .release_session(
13046                    &lease_id,
13047                    self.incarnation_id,
13048                    address.session.instance_id,
13049                    address.session.generation,
13050                    unix_time_ms(),
13051                );
13052            if self.persist_state().is_err() {
13053                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13054            }
13055            if let Some(snapshot) = snapshot {
13056                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13057            }
13058            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13059        }
13060        Ok(())
13061    }
13062
13063    /// Polls until `address`'s session leaves the engine's own session map,
13064    /// or the bounded deadline expires.
13065    ///
13066    /// A `Remove` sent while the session is not yet in a terminal status
13067    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
13068    /// once by the engine (`SessionStatus::allows_remove`) and
13069    /// then discarded -- nothing re-issues it on its own, so the removal
13070    /// that would satisfy this wait never arrives and the caller was
13071    /// guaranteed to time out even though the session settles microseconds
13072    /// later. So this re-dispatches on every poll tick: a session that
13073    /// settles mid-wait -- the common case right after a `Stop` -- still
13074    /// gets removed inside the same bounded deadline.
13075    ///
13076    /// It re-dispatches only once the status will ACCEPT the command. A
13077    /// rejected re-dispatch is not the harmless no-op this loop first
13078    /// assumed: every rejection publishes a `ControlEventKind::Command
13079    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
13080    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
13081    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
13082    /// out to the harness and every attached client. Gated, the same
13083    /// teardown sends exactly one `Remove`, at the first tick the engine
13084    /// can act on it.
13085    ///
13086    /// The status is read from the snapshot this loop already takes, so
13087    /// the gate costs nothing extra. It races -- the status can move
13088    /// between the read and the dispatch -- and that is fine: a rare lost
13089    /// race is one rejection, and the next tick retries.
13090    ///
13091    /// Re-dispatch is separately rate-limited to
13092    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
13093    /// leaves a second burst behind it. Once the session settles, every
13094    /// 2ms tick sent another `Remove` while the accepted one was still in
13095    /// flight, and each of those landed on an instance the engine had
13096    /// already dropped -- `agent instance … is not registered`, observed
13097    /// live as a tail of rejections immediately after the `session
13098    /// removed` line. The presence poll stays at 2ms so the wait still
13099    /// exits promptly; only the sending slows down.
13100    ///
13101    /// Rate-limited rather than once-only on purpose: the status gate
13102    /// covers the status precondition, but `Gate4AgentEngine::remove` has
13103    /// others (a pending operation, an in-flight capability probe or
13104    /// history request) that clear on their own. A single shot would turn
13105    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
13106    /// get their retries, just twenty of them across the deadline instead
13107    /// of a thousand.
13108    async fn wait_until_removed(
13109        &self,
13110        address: &SessionAddress,
13111        commit_timeout: Duration,
13112    ) -> Result<(), NodeFailure> {
13113        let deadline = Instant::now() + commit_timeout;
13114        let mut next_dispatch = Instant::now();
13115        loop {
13116            let snapshot = self.handle.snapshot();
13117            let status = snapshot
13118                .sessions
13119                .iter()
13120                .find(|session| session.instance_id == address.session.instance_id)
13121                .map(|session| session.status.clone());
13122            let Some(status) = status else {
13123                return Ok(());
13124            };
13125            let now = Instant::now();
13126            if now >= deadline {
13127                return Err(failure(
13128                    NodeFailureCode::BackendBusy,
13129                    "remove did not commit before the bounded deadline; session binding was retained",
13130                ));
13131            }
13132            if status.allows_remove() && now >= next_dispatch {
13133                let _ = self.dispatch(ControlCommand::Remove {
13134                    instance_id: address.session.instance_id,
13135                });
13136                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13137            }
13138            sleep(Duration::from_millis(2)).await;
13139        }
13140    }
13141
13142    fn resync(&self, after_sequence: u64) -> NodeResponse {
13143        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13144        let event_sequence = history.last_sequence;
13145        let oldest_available_sequence = history.replay_floor_sequence;
13146        let events = history
13147            .events
13148            .iter()
13149            .filter(|event| event.sequence > after_sequence)
13150            .cloned()
13151            .collect();
13152        drop(history);
13153        NodeResponse::Resync {
13154            event_sequence,
13155            oldest_available_sequence,
13156            snapshot: self.snapshot(),
13157            events,
13158        }
13159    }
13160}
13161
13162/// Coarse label for one `AgentStreamChunkKindV1`, used only by
13163/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
13164/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
13165/// "name what crossed this boundary" reasoning, one layer upstream of it).
13166fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13167    match kind {
13168        AgentStreamChunkKindV1::Text { .. } => "text",
13169        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13170        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13171        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13172        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13173        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13174        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13175    }
13176}
13177
13178/// Serves this wire on the local endpoint, and -- when `call_home` names a
13179/// relay -- over an outbound connection to it as well.
13180///
13181/// The two live in one future rather than a fourth arm of `run`'s own
13182/// `select!` because they are one concern: this is where the node's wire
13183/// is served, and the direction a given connection was opened in does not
13184/// change what happens on it. It also means the existing graceful drain
13185/// below covers both without being duplicated.
13186///
13187/// Either side finishing ends the pair, which is correct: the listener
13188/// only returns on shutdown or a bind failure, and `call_home_loop` only
13189/// returns on shutdown -- a failed relay connection is retried inside it,
13190/// never propagated out.
13191async fn accept_connections(
13192    endpoint: &str,
13193    call_home: Option<std::net::SocketAddr>,
13194    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13195    shared: Arc<NodeShared>,
13196) -> Result<(), NodeServerError> {
13197    let mut connections = JoinSet::new();
13198    let result = match call_home {
13199        None => accept_connections_inner(endpoint, shared, &mut connections).await,
13200        Some(relay) => {
13201            let listener =
13202                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13203            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13204            tokio::pin!(listener);
13205            tokio::pin!(dialler);
13206            tokio::select! {
13207                result = &mut listener => result,
13208                result = &mut dialler => result,
13209            }
13210        }
13211    };
13212    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13213    while !connections.is_empty() {
13214        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13215        if remaining.is_zero()
13216            || timeout(remaining, connections.join_next()).await.is_err()
13217        {
13218            break;
13219        }
13220    }
13221    connections.abort_all();
13222    while connections.join_next().await.is_some() {}
13223    result
13224}
13225
13226/// Backoff ladder for a relay that will not answer, capped rather than
13227/// unbounded: a node whose relay is down is not broken and should keep
13228/// trying forever, but it should not spend a core doing it.
13229const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13230
13231/// Dials the relay, serves this wire over the connection, and does it
13232/// again when the connection ends.
13233///
13234/// The node is the SERVER on the socket it just opened. That inversion is
13235/// the whole point: `serve_connection` is the same function the local
13236/// listener feeds, and it neither knows nor cares which end placed the
13237/// call. The one thing this direction adds is the preface -- the relay
13238/// accepted a socket and cannot know whose it is, so the node says its
13239/// name before the handshake it would otherwise begin with.
13240///
13241/// Never returns an error. A relay that is down, refusing, or speaking a
13242/// different protocol is a condition to retry, not a reason to take the
13243/// node's local endpoint down with it; the only way out is shutdown.
13244async fn call_home_loop(
13245    relay: std::net::SocketAddr,
13246    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13247    shared: Arc<NodeShared>,
13248) -> Result<(), NodeServerError> {
13249    let mut failures = 0_usize;
13250    loop {
13251        if shared.shutdown.load(Ordering::Acquire) {
13252            return Ok(());
13253        }
13254        let permit = tokio::select! {
13255            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13256                permit.map_err(|_| NodeServerError::PreauthClosed)?
13257            }
13258            _ = shared.shutdown_notify.notified() => return Ok(()),
13259        };
13260        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13261            Ok(()) => {
13262                // A clean end is the relay hanging up, which it does on
13263                // its own restart. Reconnect immediately rather than
13264                // serving the backoff a peer that is plainly there.
13265                failures = 0;
13266                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13267            }
13268            Err(error) => {
13269                failures = failures.saturating_add(1);
13270                tracing::warn!(
13271                    node_id = %shared.node_id,
13272                    %relay,
13273                    cause = %error,
13274                    attempt = failures,
13275                    "call-home connection failed",
13276                );
13277                let backoff = call_home_backoff(failures);
13278                tokio::select! {
13279                    _ = sleep(backoff) => {}
13280                    _ = shared.shutdown_notify.notified() => return Ok(()),
13281                }
13282            }
13283        }
13284    }
13285}
13286
13287/// Idle time before the first keepalive probe on a call-home socket.
13288const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13289/// Interval between keepalive probes once the link has gone quiet.
13290const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13291
13292fn call_home_keepalive() -> socket2::TcpKeepalive {
13293    let keepalive = socket2::TcpKeepalive::new()
13294        .with_time(CALL_HOME_KEEPALIVE_IDLE)
13295        .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13296    #[cfg(not(windows))]
13297    let keepalive = keepalive.with_retries(3);
13298    keepalive
13299}
13300
13301fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13302    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13303}
13304
13305/// Same keepalive profile for any node->relay socket (restream uplink).
13306pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13307    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13308}
13309
13310/// Backoff for the `attempt`-th consecutive failure (1-based), capped.
13311fn call_home_backoff(attempt: usize) -> Duration {
13312    Duration::from_millis(
13313        CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13314    )
13315}
13316
13317
13318/// Dial the call-home relay. Tunnel addresses go through the in-process
13319/// WireGuard dialer when the userspace session is up.
13320async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13321    #[cfg(feature = "wireguard")]
13322    {
13323        let std_stream = tokio::task::spawn_blocking(move || {
13324            gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13325        })
13326        .await
13327        .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13328        .map_err(NodeServerError::CallHomeTransport)?;
13329        std_stream
13330            .set_nonblocking(true)
13331            .map_err(NodeServerError::CallHomeTransport)?;
13332        return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13333    }
13334    #[cfg(not(feature = "wireguard"))]
13335    {
13336        tokio::net::TcpStream::connect(relay)
13337            .await
13338            .map_err(NodeServerError::CallHomeTransport)
13339    }
13340}
13341
13342/// One call-home attempt: connect, name this node, then serve.
13343async fn dial_relay_and_serve(
13344    relay: std::net::SocketAddr,
13345    identity: Option<&hatchery_enroll::NodeIdentity>,
13346    shared: Arc<NodeShared>,
13347    preauth_permit: OwnedSemaphorePermit,
13348) -> Result<(), NodeServerError> {
13349    let mut stream = dial_call_home(relay).await?;
13350    // Nagle off for the same reason the rest of this wire wants it off:
13351    // the traffic is small request/response frames, and coalescing them
13352    // adds latency to every one.
13353    let _ = stream.set_nodelay(true);
13354    // A relay that vanishes without a FIN/RST reaching us (tunnel drop,
13355    // relay-side socket kill whose RST was lost) would otherwise leave this
13356    // socket ESTABLISHED forever: the node mostly reads, so nothing ever
13357    // fails and the reconnect loop never runs. Kernel keepalive turns that
13358    // half-open link into a read error within a bounded time.
13359    if let Err(error) = enable_call_home_keepalive(&stream) {
13360        tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13361    }
13362    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13363        .await
13364        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13365    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13366    let mut session_token = None;
13367    if let Some(identity) = identity {
13368        // An enrolled relay answers the announce with a signed-call-home challenge; a
13369        // token-mode relay sends its wire hello instead. Peek to tell them apart
13370        // without consuming either.
13371        let mut magic = [0_u8; 4];
13372        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13373            .await
13374            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13375            .map_err(NodeServerError::CallHomeTransport)?;
13376        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13377            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13378            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13379                .await
13380                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13381                .map_err(NodeServerError::CallHomeTransport)?;
13382            let (answer, token) = identity
13383                .answer_challenge(shared.node_id.as_str(), &frame)
13384                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13385            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13386            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13387            session_token = Some(token);
13388        }
13389    }
13390    serve_connection(stream, shared, session_token, preauth_permit).await
13391}
13392
13393async fn accept_connections_inner(
13394    endpoint: &str,
13395    shared: Arc<NodeShared>,
13396    connections: &mut JoinSet<Result<(), NodeServerError>>,
13397) -> Result<(), NodeServerError> {
13398    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13399    loop {
13400        while connections.try_join_next().is_some() {}
13401        if shared.shutdown.load(Ordering::Acquire) {
13402            return Ok(());
13403        }
13404        let preauth_permit = tokio::select! {
13405            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13406                permit.map_err(|_| NodeServerError::PreauthClosed)?
13407            }
13408            _ = shared.shutdown_notify.notified() => return Ok(()),
13409        };
13410        let server = tokio::select! {
13411            result = listener.accept() => result?,
13412            _ = shared.shutdown_notify.notified() => return Ok(()),
13413        };
13414        let connection_shared = Arc::clone(&shared);
13415        connections.spawn(async move {
13416            serve_connection(server, connection_shared, None, preauth_permit).await
13417        });
13418        if shared.shutdown.load(Ordering::Acquire) {
13419            return Ok(());
13420        }
13421    }
13422}
13423
13424/// Serves one connection, whichever end opened it.
13425///
13426/// Generic over the stream rather than typed to `LocalServerStream`
13427/// because a call-home connection arrives on a `TcpStream` the node itself
13428/// dialled. Nothing in the body ever depended on the concrete type -- it
13429/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13430/// the whole of what welding "who dials" to "who serves" was costing.
13431async fn serve_connection<S>(
13432    mut pipe: S,
13433    shared: Arc<NodeShared>,
13434    session_token: Option<String>,
13435    preauth_permit: OwnedSemaphorePermit,
13436) -> Result<(), NodeServerError>
13437where
13438    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13439{
13440    // A signed call-home connection authenticates with its own per-connection token.
13441    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13442    let hello_frame = timeout(
13443        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13444        read_json_frame_limited_body_timeout(
13445            &mut pipe,
13446            MAX_NODE_HELLO_FRAME_BYTES,
13447            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13448        ),
13449    )
13450    .await
13451    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13452    let ClientFrame::Hello(hello) = hello_frame else {
13453        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13454    };
13455    if hello.build_stamp != BUILD_STAMP {
13456        return Err(NodeServerError::Handshake(format!(
13457            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13458            hello.build_stamp,
13459        )));
13460    }
13461    let compatibility = match hello.compatibility.as_ref() {
13462        Some(offer) => {
13463            let mut selected = node_compatibility_support(&shared)?
13464                .negotiate(offer)
13465                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13466            project_negotiated_provider_ids(&mut selected);
13467            Some(selected)
13468        }
13469        None => None,
13470    };
13471    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13472    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13473        (Some(offer), Some(selected)) => negotiated_auth_proof(
13474            auth_token,
13475            AuthDirection::Server,
13476            hello.role,
13477            &hello.client_nonce,
13478            &server_nonce,
13479            offer,
13480            selected,
13481        ),
13482        (None, None) => auth_proof(
13483            auth_token,
13484            AuthDirection::Server,
13485            hello.role,
13486            &hello.client_nonce,
13487            &server_nonce,
13488        ),
13489        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13490    }
13491    .map_err(NodeServerError::Authentication)?;
13492    write_json_frame_limited(
13493        &mut pipe,
13494        &ServerFrame::Challenge(ServerChallenge {
13495            build_stamp: BUILD_STAMP.to_owned(),
13496            server_nonce,
13497            server_proof,
13498            compatibility: compatibility.clone(),
13499        }),
13500        MAX_NODE_HELLO_FRAME_BYTES,
13501    )
13502    .await?;
13503    let authentication = timeout(
13504        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13505        read_json_frame_limited_body_timeout(
13506            &mut pipe,
13507            MAX_NODE_HELLO_FRAME_BYTES,
13508            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13509        ),
13510    )
13511    .await
13512    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13513    let ClientFrame::Authenticate(authentication) = authentication else {
13514        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13515    };
13516    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13517        (Some(offer), Some(selected)) => negotiated_auth_proof(
13518            auth_token,
13519            AuthDirection::Client,
13520            hello.role,
13521            &hello.client_nonce,
13522            &server_nonce,
13523            offer,
13524            selected,
13525        ),
13526        (None, None) => auth_proof(
13527            auth_token,
13528            AuthDirection::Client,
13529            hello.role,
13530            &hello.client_nonce,
13531            &server_nonce,
13532        ),
13533        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13534    }
13535    .map_err(NodeServerError::Authentication)?;
13536    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13537        return Err(NodeServerError::Handshake("access denied".to_owned()));
13538    }
13539    let selected_capabilities = compatibility
13540        .as_ref()
13541        .map(|selected| selected.capabilities.clone())
13542        .unwrap_or_default();
13543    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13544        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13545    });
13546    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13547        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13548    });
13549    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13550        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13551    });
13552    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13553        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13554    });
13555    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13556        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13557    });
13558    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13559        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13560    }) && selected_capabilities.iter().any(|capability| {
13561        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13562    });
13563    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13564        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13565    });
13566    let include_session_bundles = selected_capabilities.iter().any(|capability| {
13567        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13568    });
13569    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13570        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13571    });
13572    let include_agent_progress = selected_capabilities.iter().any(|capability| {
13573        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13574    });
13575    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13576        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13577    });
13578    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13579        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13580    });
13581    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13582        .try_acquire_owned()
13583        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13584    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13585    let _release_guard = ControllerReleaseGuard {
13586        shared: Arc::clone(&shared),
13587        connection_id,
13588    };
13589    drop(preauth_permit);
13590    let _authenticated_permit = authenticated_permit;
13591    let mut event_rx = shared.event_tx.subscribe();
13592    let mut terminal_event_rx = terminal_event_subscription(
13593        &shared,
13594        include_terminal_frame_events,
13595    );
13596    let mut agent_stream_event_rx = agent_stream_event_subscription(
13597        &shared,
13598        include_agent_stream_events,
13599    );
13600    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13601        &shared,
13602        include_harness_mcp_proxy,
13603    );
13604    write_json_frame(
13605        &mut pipe,
13606        &ServerFrame::Hello(NodeHello {
13607            build_stamp: BUILD_STAMP.to_owned(),
13608            incarnation_id: shared.incarnation_id,
13609            connection_id,
13610            role: hello.role,
13611            event_sequence: shared.current_sequence(),
13612            controller: shared.controller_state(),
13613            snapshot: snapshot_for_wire(
13614                &shared,
13615                include_provider_runtime_status,
13616                include_open_provider_ids,
13617                include_spawn_profiles,
13618                include_managed_worktrees,
13619                include_child_environment_profiles,
13620                include_session_bundles,
13621                include_history_context_packs,
13622                include_agent_progress,
13623                include_session_task_correlation,
13624            ),
13625            compatibility,
13626        }),
13627    )
13628    .await?;
13629    let (mut reader, mut writer) = tokio::io::split(pipe);
13630    let (frame_tx, mut frame_rx) = mpsc::channel(16);
13631    let reader_task = tokio::spawn(async move {
13632        loop {
13633            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13634                &mut reader,
13635                MAX_NODE_CLIENT_FRAME_BYTES,
13636                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13637            )
13638            .await;
13639            let terminal = frame.is_err();
13640            if frame_tx.send(frame).await.is_err() || terminal {
13641                break;
13642            }
13643        }
13644    });
13645    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13646    let mut pending_events = Vec::new();
13647    let mut resync_required = false;
13648    let mut discard_events_through = 0_u64;
13649    loop {
13650        // One tick of the serve loop, whether or not it had anything to do.
13651        // Against `connection_events_sent` this separates "busy" from
13652        // "spinning": a loop turning over thousands of times a second while
13653        // sending nothing is not serving, it is burning a core.
13654        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13655        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13656            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13657        for _ in 0..durable_drain_budget {
13658            match event_rx.try_recv() {
13659                Ok(event) => queue_connection_event(
13660                    &mut pending_events,
13661                    event,
13662                    discard_events_through,
13663                ),
13664                Err(broadcast::error::TryRecvError::Empty) => break,
13665                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13666                    resync_required = true;
13667                    break;
13668                }
13669                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13670            }
13671        }
13672        if let Some(receiver) = terminal_event_rx.as_mut() {
13673            match receiver.try_recv() {
13674                Ok(events) => queue_connection_event_batch(
13675                    &mut pending_events,
13676                    &events,
13677                    discard_events_through,
13678                ),
13679                Err(broadcast::error::TryRecvError::Empty) => {}
13680                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13681                    resync_required = true;
13682                }
13683                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13684            }
13685        }
13686        if let Some(receiver) = agent_stream_event_rx.as_mut() {
13687            // Loops up to the same remaining-room budget the durable
13688            // `event_rx` drain above uses, rather than one `try_recv` per
13689            // tick: each successful receive here is itself only ONE
13690            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
13691            // sends `vec![envelope]`, never more), so a single-shot drain
13692            // caps this connection's agent-stream throughput at one chunk
13693            // per outer-loop tick regardless of how many are already queued
13694            // -- exactly the kind of self-inflicted stall this crate's own
13695            // durable-event budget exists to avoid.
13696            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13697                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13698            for _ in 0..agent_stream_drain_budget {
13699                match receiver.try_recv() {
13700                    Ok(events) => queue_connection_event_batch(
13701                        &mut pending_events,
13702                        &events,
13703                        discard_events_through,
13704                    ),
13705                    Err(broadcast::error::TryRecvError::Empty) => break,
13706                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13707                        tracing::warn!(
13708                            connection_id,
13709                            dropped,
13710                            "agent stream event subscription lagged; forcing a resync",
13711                        );
13712                        resync_required = true;
13713                        break;
13714                    }
13715                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13716                }
13717            }
13718        }
13719        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13720            // Mirrors the agent-stream drain immediately above: each
13721            // successful receive is one single-envelope batch
13722            // (`publish_transient` always sends `vec![envelope]`), so this
13723            // loops up to the same remaining-room budget rather than
13724            // stopping after one `try_recv`.
13725            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13726                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13727            for _ in 0..harness_mcp_proxy_drain_budget {
13728                match receiver.try_recv() {
13729                    Ok(events) => queue_connection_event_batch(
13730                        &mut pending_events,
13731                        &events,
13732                        discard_events_through,
13733                    ),
13734                    Err(broadcast::error::TryRecvError::Empty) => break,
13735                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13736                        tracing::warn!(
13737                            connection_id,
13738                            dropped,
13739                            "harness MCP proxy event subscription lagged; forcing a resync",
13740                        );
13741                        resync_required = true;
13742                        break;
13743                    }
13744                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13745                }
13746            }
13747        }
13748        if resync_required {
13749            let event = resync_required_event(&shared);
13750            apply_connection_resync_watermark(
13751                &mut pending_events,
13752                &mut discard_events_through,
13753                event.sequence,
13754            );
13755            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13756            resync_required = false;
13757            continue;
13758        }
13759        if !pending_events.is_empty() {
13760            pending_events.sort_unstable_by_key(|event| event.sequence);
13761            let burst_len = connection_event_burst_len(pending_events.len());
13762            shared
13763                .connection_events_sent
13764                .fetch_add(burst_len as u64, Ordering::Relaxed);
13765            for event in pending_events.drain(..burst_len) {
13766                let event = if include_managed_worktrees {
13767                    Some(event)
13768                } else {
13769                    project_event_without_managed_worktrees(event)
13770                };
13771                let event = event.and_then(|event| if include_open_provider_ids {
13772                    Some(event)
13773                } else {
13774                    project_event_legacy_provider_ids(&shared, event)
13775                });
13776                let event = event.map(|event| if include_child_environment_profiles {
13777                    event
13778                } else {
13779                    project_event_without_child_environment_profile(event)
13780                });
13781                let event = event.map(|event| if include_session_bundles {
13782                    event
13783                } else {
13784                    project_event_without_session_bundle(event)
13785                });
13786                let event = event.map(|event| {
13787                    project_event_history_for_wire(event, include_history_context_packs)
13788                });
13789                let event = event.map(|event| if include_history_context_packs {
13790                    event
13791                } else {
13792                    project_event_without_context_pack(event)
13793                });
13794                let event = event.map(|event| if include_session_task_correlation {
13795                    event
13796                } else {
13797                    project_event_without_session_task_binding(event)
13798                });
13799                let exact_harness_controller = hello.role == ClientRole::Operator
13800                    && shared.controller_state().is_some_and(|controller| {
13801                        controller.connection_id == connection_id
13802                    });
13803                let event = event.and_then(|event| {
13804                    if include_harness_mcp_proxy && exact_harness_controller {
13805                        Some(event)
13806                    } else {
13807                        project_event_without_harness_mcp_proxy(event)
13808                    }
13809                });
13810                if let Some(event) = event {
13811                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13812                }
13813            }
13814        }
13815        tokio::select! {
13816            biased;
13817            frame = frame_rx.recv() => {
13818                let frame = match frame {
13819                    Some(frame) => frame,
13820                    None => break,
13821                };
13822                let frame = match frame {
13823                    Ok(frame) => frame,
13824                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13825                    Err(error) => return Err(error.into()),
13826                };
13827                let ClientFrame::Request(request) = frame else {
13828                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13829                };
13830                #[cfg(feature = "fixture")]
13831                let probe_spawn_managed_worktree_v2 = matches!(
13832                    &request.request,
13833                    NodeRequest::SpawnManagedWorktreeV2 { .. }
13834                );
13835                let requires_open_provider_ids =
13836                    request_requires_open_provider_ids(&shared, &request.request);
13837                let requires_child_environment_profile =
13838                    request_requires_child_environment_profile(&shared, &request.request);
13839                let requires_session_bundle =
13840                    request_requires_session_bundle(&shared, &request.request);
13841                let requires_history_context_pack =
13842                    request_requires_history_context_pack(&shared, &request.request);
13843                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13844                    ResponseEnvelope {
13845                        request_id: request.request_id,
13846                        result: Err(failure(
13847                            NodeFailureCode::UnsupportedCapability,
13848                            "open provider IDs were not negotiated",
13849                        )),
13850                    }
13851                } else if requires_child_environment_profile
13852                    && !include_child_environment_profiles
13853                {
13854                    ResponseEnvelope {
13855                        request_id: request.request_id,
13856                        result: Err(failure(
13857                            NodeFailureCode::UnsupportedCapability,
13858                            "child environment profiles were not negotiated",
13859                        )),
13860                    }
13861                } else if requires_session_bundle && !include_session_bundles {
13862                    ResponseEnvelope {
13863                        request_id: request.request_id,
13864                        result: Err(failure(
13865                            NodeFailureCode::UnsupportedCapability,
13866                            "session bundles were not negotiated",
13867                        )),
13868                    }
13869                } else if requires_history_context_pack && !include_history_context_packs {
13870                    ResponseEnvelope {
13871                        request_id: request.request_id,
13872                        result: Err(failure(
13873                            NodeFailureCode::UnsupportedCapability,
13874                            "history context packs were not negotiated",
13875                        )),
13876                    }
13877                } else if request_uses_unnegotiated_capability(
13878                    &request.request,
13879                    &selected_capabilities,
13880                )
13881                {
13882                    ResponseEnvelope {
13883                        request_id: request.request_id,
13884                        result: Err(failure(
13885                            NodeFailureCode::UnsupportedCapability,
13886                            "request capability was not negotiated",
13887                        )),
13888                    }
13889                } else {
13890                    process_request(&shared, connection_id, hello.role, request).await
13891                };
13892                #[cfg(feature = "fixture")]
13893                if probe_spawn_managed_worktree_v2 {
13894                    shared
13895                        .fixture_spawn_managed_worktree_v2_failure_probe
13896                        .record(reply.result.as_ref().err().map(|failure| failure.code));
13897                }
13898                if !include_provider_runtime_status {
13899                    clear_response_provider_runtime_status(&mut reply);
13900                }
13901                if !include_managed_worktrees {
13902                    project_response_without_managed_worktrees(&mut reply);
13903                }
13904                if !include_open_provider_ids {
13905                    project_response_legacy_provider_ids(&shared, &mut reply);
13906                }
13907                if !include_child_environment_profiles {
13908                    project_response_without_child_environment_profile(&mut reply);
13909                }
13910                if !include_session_bundles {
13911                    project_response_without_session_bundle(&mut reply);
13912                }
13913                project_response_history_for_wire(
13914                    &mut reply,
13915                    include_history_context_packs,
13916                );
13917                if !include_history_context_packs {
13918                    project_response_without_context_pack(&mut reply);
13919                }
13920                if !include_agent_progress {
13921                    project_response_without_agent_progress(&mut reply);
13922                }
13923                if !include_session_task_correlation {
13924                    project_response_without_session_task_binding(&mut reply);
13925                }
13926                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13927            }
13928            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13929            event = event_rx.recv() => {
13930                match event {
13931                    Ok(event) => queue_connection_event(
13932                        &mut pending_events,
13933                        event,
13934                        discard_events_through,
13935                    ),
13936                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13937                    Err(broadcast::error::RecvError::Closed) => break,
13938                }
13939            }
13940            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13941                match events {
13942                    Ok(events) => queue_connection_event_batch(
13943                        &mut pending_events,
13944                        &events,
13945                        discard_events_through,
13946                    ),
13947                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13948                    Err(broadcast::error::RecvError::Closed) => break,
13949                }
13950            }
13951            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13952                match events {
13953                    Ok(events) => queue_connection_event_batch(
13954                        &mut pending_events,
13955                        &events,
13956                        discard_events_through,
13957                    ),
13958                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13959                    Err(broadcast::error::RecvError::Closed) => break,
13960                }
13961            }
13962            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13963                match events {
13964                    Ok(events) => queue_connection_event_batch(
13965                        &mut pending_events,
13966                        &events,
13967                        discard_events_through,
13968                    ),
13969                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13970                    Err(broadcast::error::RecvError::Closed) => break,
13971                }
13972            }
13973            _ = shared.shutdown_notify.notified() => break,
13974        }
13975    }
13976    Ok(())
13977}
13978
13979async fn receive_terminal_event_batch(
13980    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13981) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13982    match receiver {
13983        Some(receiver) => receiver.recv().await,
13984        None => std::future::pending().await,
13985    }
13986}
13987
13988fn terminal_event_subscription(
13989    shared: &NodeShared,
13990    enabled: bool,
13991) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13992    enabled.then(|| shared.terminal_event_tx.subscribe())
13993}
13994
13995/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
13996/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
13997/// this needs a dedicated channel rather than a flag on the terminal one.
13998fn agent_stream_event_subscription(
13999    shared: &NodeShared,
14000    enabled: bool,
14001) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14002    enabled.then(|| shared.agent_stream_event_tx.subscribe())
14003}
14004
14005/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
14006/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
14007/// own doc for why this needs a dedicated channel rather than a ride on
14008/// the durable `event_tx`.
14009fn harness_mcp_proxy_event_subscription(
14010    shared: &NodeShared,
14011    enabled: bool,
14012) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14013    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14014}
14015
14016fn queue_connection_event(
14017    pending: &mut Vec<NodeEventEnvelope>,
14018    event: NodeEventEnvelope,
14019    discard_through: u64,
14020) {
14021    if event.sequence <= discard_through
14022        && !event.event.requires_harness_mcp_proxy_capability()
14023    {
14024        return;
14025    }
14026    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14027        if let Some(index) = pending.iter().position(|current| {
14028            matches!(
14029                &current.event,
14030                NodeEvent::TerminalFrame {
14031                    address: current_address,
14032                    ..
14033                } if current_address == address
14034            )
14035        }) {
14036            pending.remove(index);
14037        }
14038    }
14039    pending.push(event);
14040}
14041
14042fn project_event_without_harness_mcp_proxy(
14043    envelope: NodeEventEnvelope,
14044) -> Option<NodeEventEnvelope> {
14045    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14046}
14047
14048fn queue_connection_event_batch(
14049    pending: &mut Vec<NodeEventEnvelope>,
14050    events: &[NodeEventEnvelope],
14051    discard_through: u64,
14052) {
14053    for event in events {
14054        queue_connection_event(pending, event.clone(), discard_through);
14055    }
14056}
14057
14058fn apply_connection_resync_watermark(
14059    pending: &mut Vec<NodeEventEnvelope>,
14060    discard_through: &mut u64,
14061    marker_sequence: u64,
14062) {
14063    *discard_through = (*discard_through).max(marker_sequence);
14064    pending.retain(|event| event.sequence > *discard_through);
14065}
14066
14067fn connection_event_burst_len(pending_len: usize) -> usize {
14068    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14069}
14070
14071fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14072    let history = shared
14073        .history
14074        .lock()
14075        .unwrap_or_else(|poisoned| poisoned.into_inner());
14076    let sequence = history.last_sequence;
14077    let oldest_available_sequence = history.replay_floor_sequence;
14078    NodeEventEnvelope {
14079        sequence,
14080        event: NodeEvent::ResyncRequired {
14081            oldest_available_sequence,
14082        },
14083    }
14084}
14085
14086fn node_compatibility_support(
14087    shared: &NodeShared,
14088) -> Result<NodeCompatibilitySupport, NodeServerError> {
14089    let mut support = node_compatibility_support_for_manifest(
14090        &shared.provider_contracts,
14091        &shared.provider_adapter_contracts,
14092    )?;
14093    if shared
14094        .delivery_store
14095        .lock()
14096        .unwrap_or_else(|poisoned| poisoned.into_inner())
14097        .is_some()
14098    {
14099        support.capabilities.push(
14100            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14101                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14102        );
14103    }
14104    if shared.harness_mcp_registry.is_some() {
14105        support.capabilities.push(
14106            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14107                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14108        );
14109    }
14110    Ok(support)
14111}
14112
14113fn node_compatibility_support_for_manifest(
14114    provider_contracts: &[ProviderContractSupport],
14115    provider_adapter_contracts: &[ProviderAdapterContractSupport],
14116) -> Result<NodeCompatibilitySupport, NodeServerError> {
14117    Ok(NodeCompatibilitySupport {
14118        build_stamp: BUILD_STAMP.to_owned(),
14119        capabilities: baseline_capabilities()?,
14120        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14121        path_semantics: platform::path_semantics(),
14122        local_transport: platform::local_transport(),
14123        state_schema: StateSchemaSupport {
14124            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14125                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14126        },
14127        provider_contracts: provider_contracts.to_vec(),
14128        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14129    })
14130}
14131
14132fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14133    let capabilities = [
14134        NODE_COMPATIBILITY_METADATA_CAPABILITY,
14135        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14136        NODE_REPOSITORY_PATH_CAPABILITY,
14137        NODE_WORKSPACE_FILE_READ_CAPABILITY,
14138        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14139        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14140        NODE_GIT_READ_CAPABILITY,
14141        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14142        NODE_PROVIDER_ID_OPEN_CAPABILITY,
14143        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14144        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14145        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14146        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14147        NODE_ACP_CONTROL_CAPABILITY,
14148        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14149        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14150        NODE_WORKTREE_SELECTION_CAPABILITY,
14151        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14152        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14153        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14154        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14155        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14156        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14157        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14158        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14159        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14160        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14161        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14162        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14163    ]
14164        .into_iter()
14165        .map(|capability| {
14166            CapabilityId::new(capability)
14167                .map_err(|error| NodeServerError::Handshake(error.to_string()))
14168        })
14169        .collect::<Result<Vec<_>, _>>()?;
14170    #[cfg(windows)]
14171    let capabilities = {
14172        let mut capabilities = capabilities;
14173        capabilities.push(
14174            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14175                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14176        );
14177        capabilities
14178    };
14179    Ok(capabilities)
14180}
14181
14182fn request_uses_unnegotiated_capability(
14183    request: &NodeRequest,
14184    selected_capabilities: &[CapabilityId],
14185) -> bool {
14186    let selected = |required: &str| {
14187        selected_capabilities
14188            .iter()
14189            .any(|capability| capability.as_str() == required)
14190    };
14191    request
14192        .required_capability()
14193        .is_some_and(|required| !selected(required))
14194        || (request.requires_worktree_selection_capability()
14195            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14196        || (request.requires_spawn_spec_defaults_overrides_capability()
14197            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14198        || (request.requires_spawn_profile_revision_capability()
14199            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14200        || (request.requires_child_environment_profile_capability()
14201            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14202        || (request.requires_session_bundle_materialization_capability()
14203            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14204        || (request.requires_history_context_pack_capability()
14205            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14206}
14207
14208fn snapshot_for_wire(
14209    shared: &NodeShared,
14210    include_provider_runtime_status: bool,
14211    include_open_provider_ids: bool,
14212    include_spawn_profiles: bool,
14213    include_managed_worktrees: bool,
14214    include_child_environment_profiles: bool,
14215    include_session_bundles: bool,
14216    include_history_context_packs: bool,
14217    include_agent_progress: bool,
14218    include_session_task_correlation: bool,
14219) -> NodeSnapshot {
14220    let mut snapshot = shared.snapshot();
14221    if !include_provider_runtime_status {
14222        snapshot.provider_runtime_statuses.clear();
14223    }
14224    if !include_open_provider_ids {
14225        project_snapshot_legacy_provider_ids(&mut snapshot);
14226    }
14227    if !include_managed_worktrees {
14228        snapshot.managed_worktrees.clear();
14229        for workspace in &mut snapshot.workspaces {
14230            workspace.managed_worktree_profiles = None;
14231        }
14232    }
14233    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14234        if !include_spawn_profiles {
14235            inventory.spawn_profiles = None;
14236        }
14237        if !include_session_bundles {
14238            inventory.bundles = None;
14239        }
14240        if inventory.spawn_profiles.is_none()
14241            && inventory.bundles.is_none()
14242            && inventory.network_allowlists.is_none()
14243        {
14244            snapshot.launch_inventory = None;
14245        }
14246    }
14247    if !include_child_environment_profiles {
14248        clear_snapshot_child_environment_profiles(&mut snapshot);
14249    }
14250    if !include_session_bundles {
14251        clear_snapshot_session_bundles(&mut snapshot);
14252    }
14253    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14254    if !include_history_context_packs {
14255        clear_snapshot_context_packs(&mut snapshot);
14256    }
14257    if !include_agent_progress {
14258        snapshot.agent_progress.clear();
14259    }
14260    if !include_session_task_correlation {
14261        clear_snapshot_session_task_bindings(&mut snapshot);
14262    }
14263    snapshot
14264}
14265
14266fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14267    for record in &mut snapshot.session_records {
14268        record.task_binding = None;
14269    }
14270}
14271
14272fn project_event_without_session_task_binding(
14273    mut envelope: NodeEventEnvelope,
14274) -> NodeEventEnvelope {
14275    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14276        record.task_binding = None;
14277    }
14278    envelope
14279}
14280
14281fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14282    let Ok(response) = reply.result.as_mut() else { return };
14283    match response {
14284        NodeResponse::Snapshot { snapshot, .. } => {
14285            clear_snapshot_session_task_bindings(snapshot);
14286        }
14287        NodeResponse::Resync { snapshot, events, .. } => {
14288            clear_snapshot_session_task_bindings(snapshot);
14289            for event in events {
14290                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14291                    record.task_binding = None;
14292                }
14293            }
14294        }
14295        NodeResponse::SessionRecordUpdated { record }
14296        | NodeResponse::ProviderSessionIndexed { record }
14297        | NodeResponse::NativeSessionIndexed { record, .. }
14298        | NodeResponse::SessionRecordResumed { record, .. } => {
14299            record.task_binding = None;
14300        }
14301        _ => {}
14302    }
14303}
14304
14305fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14306    let Ok(response) = reply.result.as_mut() else { return };
14307    match response {
14308        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14309            snapshot.agent_progress.clear();
14310        }
14311        _ => {}
14312    }
14313}
14314
14315fn project_event_without_managed_worktrees(
14316    mut envelope: NodeEventEnvelope,
14317) -> Option<NodeEventEnvelope> {
14318    if matches!(
14319        envelope.event,
14320        NodeEvent::ManagedWorktreeUpserted { .. }
14321            | NodeEvent::ManagedWorktreeRemoved { .. }
14322    ) {
14323        return None;
14324    }
14325    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14326        workspace.managed_worktree_profiles = None;
14327    }
14328    Some(envelope)
14329}
14330
14331fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14332    if matches!(
14333        reply.result,
14334        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14335            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14336    ) {
14337        reply.result = Err(failure(
14338            NodeFailureCode::UnsupportedCapability,
14339            "managed worktree capability was not negotiated",
14340        ));
14341        return;
14342    }
14343    let Ok(response) = reply.result.as_mut() else { return };
14344    match response {
14345        NodeResponse::Snapshot { snapshot, .. } => {
14346            snapshot.managed_worktrees.clear();
14347            for workspace in &mut snapshot.workspaces {
14348                workspace.managed_worktree_profiles = None;
14349            }
14350        }
14351        NodeResponse::Resync { snapshot, events, .. } => {
14352            snapshot.managed_worktrees.clear();
14353            for workspace in &mut snapshot.workspaces {
14354                workspace.managed_worktree_profiles = None;
14355            }
14356            *events = std::mem::take(events)
14357                .into_iter()
14358                .filter_map(project_event_without_managed_worktrees)
14359                .collect();
14360        }
14361        NodeResponse::WorkspaceRegistered { workspace }
14362        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14363        | NodeResponse::WorktreeCreated { workspace, .. } => {
14364            workspace.managed_worktree_profiles = None;
14365        }
14366        NodeResponse::WorkspaceInspected { inspection } => {
14367            inspection.git.managed_worktree = None;
14368        }
14369        _ => {}
14370    }
14371}
14372
14373fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14374    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14375        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14376    });
14377    if includes_open_provider_ids {
14378        return;
14379    }
14380    compatibility
14381        .provider_contracts
14382        .retain(|contract| provider_id_is_legacy(&contract.provider));
14383    compatibility
14384        .provider_adapter_contracts
14385        .retain(|contract| provider_id_is_legacy(&contract.provider));
14386}
14387
14388fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14389    if let Some(spec) = match request {
14390        NodeRequest::SpawnSpec { spec }
14391        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14392        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14393        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14394        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14395        _ => None,
14396    } {
14397        return shared
14398            .resolve_spawn_spec(spec)
14399            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14400    }
14401    request_requires_open_provider_ids_with(
14402        request,
14403        |session| shared.validate_address(session).ok(),
14404        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14405    )
14406}
14407
14408fn request_requires_child_environment_profile(
14409    shared: &NodeShared,
14410    request: &NodeRequest,
14411) -> bool {
14412    if let Some(spec) = match request {
14413        NodeRequest::SpawnSpec { spec }
14414        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14415        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14416        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14417        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14418        _ => None,
14419    } {
14420        return shared
14421            .resolve_spawn_spec(spec)
14422            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14423    }
14424    match request {
14425        NodeRequest::Resume { session, .. }
14426        | NodeRequest::Prompt { session, .. }
14427        | NodeRequest::Paste { session, .. }
14428        | NodeRequest::Input { session, .. }
14429        | NodeRequest::TerminalBytes { session, .. }
14430        | NodeRequest::TerminalControl { session, .. }
14431        | NodeRequest::Resize { session, .. }
14432        | NodeRequest::Interrupt { session }
14433        | NodeRequest::Stop { session, .. }
14434        | NodeRequest::Remove { session }
14435        | NodeRequest::ResolveInteraction { session, .. }
14436        | NodeRequest::SetSessionMode { session, .. }
14437        | NodeRequest::SetSessionConfigOption { session, .. }
14438        | NodeRequest::SetSessionModel { session, .. }
14439        | NodeRequest::DiscoverHistory { session, .. }
14440        | NodeRequest::LoadHistory { session, .. }
14441        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14442        | NodeRequest::ExportContextPack { session } => shared
14443            .session_bindings
14444            .lock()
14445            .unwrap_or_else(|poisoned| poisoned.into_inner())
14446            .get(&session.session.instance_id)
14447            .filter(|binding| {
14448                binding.workspace_id == session.workspace_id
14449                    && binding.generation == session.session.generation
14450            })
14451            .map_or(true, |binding| binding.environment_profile.is_some()),
14452        NodeRequest::RenameSessionRecord { record_id, .. }
14453        | NodeRequest::SetSessionTask { record_id, .. }
14454        | NodeRequest::ResumeSessionRecord { record_id, .. }
14455        | NodeRequest::ForgetSessionRecord { record_id } => shared
14456            .record(record_id)
14457            .map_or(true, |record| record.environment_profile.is_some()),
14458        NodeRequest::ArmHarnessMcpReservation { .. }
14459        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14460        | NodeRequest::ActivateHarnessMcpReservation { .. }
14461        | NodeRequest::AbortHarnessMcpReservation { .. }
14462        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14463        | NodeRequest::RejectHarnessMcpCall { .. }
14464        | NodeRequest::Snapshot
14465        | NodeRequest::Resync { .. }
14466        | NodeRequest::BeginDeliveryStage { .. }
14467        | NodeRequest::PutDeliveryBlobChunk { .. }
14468        | NodeRequest::CommitDeliveryStage { .. }
14469        | NodeRequest::AbortDeliveryStage { .. }
14470        | NodeRequest::BrowseHostDirectories { .. }
14471        | NodeRequest::InspectWorkspace { .. }
14472        | NodeRequest::ReadWorkspaceFile { .. }
14473        | NodeRequest::WriteWorkspaceFile { .. }
14474        | NodeRequest::CreateWorkspaceFile { .. }
14475        | NodeRequest::CreateWorkspaceDirectory { .. }
14476        | NodeRequest::ReadGitHistory { .. }
14477        | NodeRequest::ReadGitDiff { .. }
14478        | NodeRequest::AcquireController { .. }
14479        | NodeRequest::ReleaseController
14480        | NodeRequest::Spawn { .. }
14481        | NodeRequest::SpawnWithOptions { .. }
14482        | NodeRequest::SpawnSpec { .. }
14483        | NodeRequest::RegisterWorkspace { .. }
14484        | NodeRequest::CreateStandaloneWorkspace { .. }
14485        | NodeRequest::UnregisterWorkspace { .. }
14486        | NodeRequest::CreateWorktree { .. }
14487        | NodeRequest::RemoveWorktree { .. }
14488        | NodeRequest::SpawnManagedWorktree { .. }
14489        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14490        | NodeRequest::CleanupManagedWorktree { .. }
14491        | NodeRequest::IndexProviderSession { .. }
14492        | NodeRequest::IndexNativeSession { .. }
14493        | NodeRequest::CatalogNativeSessions { .. }
14494        | NodeRequest::PageNativeSessions { .. }
14495        | NodeRequest::PreviewNativeSession { .. }
14496        | NodeRequest::PreviewSessionRecord { .. }
14497        | NodeRequest::ForgetContextPack { .. }
14498        | NodeRequest::ResolveDurableContextPack { .. }
14499        | NodeRequest::ReadContextPack { .. }
14500        | NodeRequest::Shutdown => false,
14501    }
14502}
14503
14504fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14505    if let Some(spec) = match request {
14506        NodeRequest::SpawnSpec { spec }
14507        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14508        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14509        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14510        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14511        _ => None,
14512    } {
14513        return shared
14514            .resolve_spawn_spec(spec)
14515            .is_ok_and(|resolved| resolved.bundle_id.is_some());
14516    }
14517    match request {
14518        NodeRequest::Resume { session, .. }
14519        | NodeRequest::Prompt { session, .. }
14520        | NodeRequest::Paste { session, .. }
14521        | NodeRequest::Input { session, .. }
14522        | NodeRequest::TerminalBytes { session, .. }
14523        | NodeRequest::TerminalControl { session, .. }
14524        | NodeRequest::Resize { session, .. }
14525        | NodeRequest::Interrupt { session }
14526        | NodeRequest::Stop { session, .. }
14527        | NodeRequest::Remove { session }
14528        | NodeRequest::ResolveInteraction { session, .. }
14529        | NodeRequest::SetSessionMode { session, .. }
14530        | NodeRequest::SetSessionConfigOption { session, .. }
14531        | NodeRequest::SetSessionModel { session, .. }
14532        | NodeRequest::DiscoverHistory { session, .. }
14533        | NodeRequest::LoadHistory { session, .. }
14534        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14535        | NodeRequest::ExportContextPack { session } => shared
14536            .session_bindings
14537            .lock()
14538            .unwrap_or_else(|poisoned| poisoned.into_inner())
14539            .get(&session.session.instance_id)
14540            .filter(|binding| {
14541                binding.workspace_id == session.workspace_id
14542                    && binding.generation == session.session.generation
14543            })
14544            .map_or(true, |binding| binding.bundle.is_some()),
14545        NodeRequest::RenameSessionRecord { record_id, .. }
14546        | NodeRequest::SetSessionTask { record_id, .. }
14547        | NodeRequest::ResumeSessionRecord { record_id, .. }
14548        | NodeRequest::ForgetSessionRecord { record_id } => shared
14549            .record(record_id)
14550            .map_or(true, |record| record.bundle.is_some()),
14551        NodeRequest::ArmHarnessMcpReservation { .. }
14552        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14553        | NodeRequest::ActivateHarnessMcpReservation { .. }
14554        | NodeRequest::AbortHarnessMcpReservation { .. }
14555        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14556        | NodeRequest::RejectHarnessMcpCall { .. }
14557        | NodeRequest::Snapshot
14558        | NodeRequest::Resync { .. }
14559        | NodeRequest::BeginDeliveryStage { .. }
14560        | NodeRequest::PutDeliveryBlobChunk { .. }
14561        | NodeRequest::CommitDeliveryStage { .. }
14562        | NodeRequest::AbortDeliveryStage { .. }
14563        | NodeRequest::BrowseHostDirectories { .. }
14564        | NodeRequest::InspectWorkspace { .. }
14565        | NodeRequest::ReadWorkspaceFile { .. }
14566        | NodeRequest::WriteWorkspaceFile { .. }
14567        | NodeRequest::CreateWorkspaceFile { .. }
14568        | NodeRequest::CreateWorkspaceDirectory { .. }
14569        | NodeRequest::ReadGitHistory { .. }
14570        | NodeRequest::ReadGitDiff { .. }
14571        | NodeRequest::AcquireController { .. }
14572        | NodeRequest::ReleaseController
14573        | NodeRequest::Spawn { .. }
14574        | NodeRequest::SpawnWithOptions { .. }
14575        | NodeRequest::SpawnSpec { .. }
14576        | NodeRequest::RegisterWorkspace { .. }
14577        | NodeRequest::CreateStandaloneWorkspace { .. }
14578        | NodeRequest::UnregisterWorkspace { .. }
14579        | NodeRequest::CreateWorktree { .. }
14580        | NodeRequest::RemoveWorktree { .. }
14581        | NodeRequest::SpawnManagedWorktree { .. }
14582        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14583        | NodeRequest::CleanupManagedWorktree { .. }
14584        | NodeRequest::IndexProviderSession { .. }
14585        | NodeRequest::IndexNativeSession { .. }
14586        | NodeRequest::CatalogNativeSessions { .. }
14587        | NodeRequest::PageNativeSessions { .. }
14588        | NodeRequest::PreviewNativeSession { .. }
14589        | NodeRequest::PreviewSessionRecord { .. }
14590        | NodeRequest::ForgetContextPack { .. }
14591        | NodeRequest::ResolveDurableContextPack { .. }
14592        | NodeRequest::ReadContextPack { .. }
14593        | NodeRequest::Shutdown => false,
14594    }
14595}
14596
14597fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14598    if request.requires_history_context_pack_capability() {
14599        return true;
14600    }
14601    if let Some(spec) = match request {
14602        NodeRequest::SpawnSpec { spec }
14603        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14604        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14605        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14606        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14607        _ => None,
14608    } {
14609        return shared
14610            .resolve_spawn_spec(spec)
14611            .map_or(true, |resolved| resolved.context_id.is_some());
14612    }
14613    match request {
14614        NodeRequest::Resume { session, .. }
14615        | NodeRequest::Prompt { session, .. }
14616        | NodeRequest::Paste { session, .. }
14617        | NodeRequest::Input { session, .. }
14618        | NodeRequest::TerminalBytes { session, .. }
14619        | NodeRequest::TerminalControl { session, .. }
14620        | NodeRequest::Resize { session, .. }
14621        | NodeRequest::Interrupt { session }
14622        | NodeRequest::Stop { session, .. }
14623        | NodeRequest::Remove { session }
14624        | NodeRequest::ResolveInteraction { session, .. }
14625        | NodeRequest::SetSessionMode { session, .. }
14626        | NodeRequest::SetSessionConfigOption { session, .. }
14627        | NodeRequest::SetSessionModel { session, .. } => shared
14628            .session_bindings
14629            .lock()
14630            .unwrap_or_else(|poisoned| poisoned.into_inner())
14631            .get(&session.session.instance_id)
14632            .filter(|binding| {
14633                binding.workspace_id == session.workspace_id
14634                    && binding.generation == session.session.generation
14635            })
14636            .map_or(true, |binding| binding.context.is_some()),
14637        NodeRequest::RenameSessionRecord { record_id, .. }
14638        | NodeRequest::SetSessionTask { record_id, .. }
14639        | NodeRequest::ResumeSessionRecord { record_id, .. }
14640        | NodeRequest::ForgetSessionRecord { record_id } => shared
14641            .record(record_id)
14642            .map_or(true, |record| record.context.is_some()),
14643        NodeRequest::ArmHarnessMcpReservation { .. }
14644        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14645        | NodeRequest::ActivateHarnessMcpReservation { .. }
14646        | NodeRequest::AbortHarnessMcpReservation { .. }
14647        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14648        | NodeRequest::RejectHarnessMcpCall { .. }
14649        | NodeRequest::Snapshot
14650        | NodeRequest::Resync { .. }
14651        | NodeRequest::BeginDeliveryStage { .. }
14652        | NodeRequest::PutDeliveryBlobChunk { .. }
14653        | NodeRequest::CommitDeliveryStage { .. }
14654        | NodeRequest::AbortDeliveryStage { .. }
14655        | NodeRequest::BrowseHostDirectories { .. }
14656        | NodeRequest::InspectWorkspace { .. }
14657        | NodeRequest::ReadWorkspaceFile { .. }
14658        | NodeRequest::WriteWorkspaceFile { .. }
14659        | NodeRequest::CreateWorkspaceFile { .. }
14660        | NodeRequest::CreateWorkspaceDirectory { .. }
14661        | NodeRequest::ReadGitHistory { .. }
14662        | NodeRequest::ReadGitDiff { .. }
14663        | NodeRequest::AcquireController { .. }
14664        | NodeRequest::ReleaseController
14665        | NodeRequest::Spawn { .. }
14666        | NodeRequest::SpawnWithOptions { .. }
14667        | NodeRequest::SpawnSpec { .. }
14668        | NodeRequest::RegisterWorkspace { .. }
14669        | NodeRequest::CreateStandaloneWorkspace { .. }
14670        | NodeRequest::UnregisterWorkspace { .. }
14671        | NodeRequest::CreateWorktree { .. }
14672        | NodeRequest::RemoveWorktree { .. }
14673        | NodeRequest::SpawnManagedWorktree { .. }
14674        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14675        | NodeRequest::CleanupManagedWorktree { .. }
14676        | NodeRequest::IndexProviderSession { .. }
14677        | NodeRequest::IndexNativeSession { .. }
14678        | NodeRequest::CatalogNativeSessions { .. }
14679        | NodeRequest::PageNativeSessions { .. }
14680        | NodeRequest::PreviewNativeSession { .. }
14681        | NodeRequest::PreviewSessionRecord { .. }
14682        | NodeRequest::DiscoverHistory { .. }
14683        | NodeRequest::LoadHistory { .. }
14684        | NodeRequest::ExportContextPackForSessionRecord { .. }
14685        | NodeRequest::ExportContextPack { .. }
14686        | NodeRequest::ForgetContextPack { .. }
14687        | NodeRequest::ResolveDurableContextPack { .. }
14688        | NodeRequest::ReadContextPack { .. }
14689        | NodeRequest::Shutdown => false,
14690    }
14691}
14692
14693fn request_requires_open_provider_ids_with(
14694    request: &NodeRequest,
14695    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14696    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14697) -> bool {
14698    match request {
14699        NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
14700        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14701        NodeRequest::CatalogNativeSessions { route, .. }
14702        | NodeRequest::PageNativeSessions { route, .. } => {
14703            !provider_id_is_legacy(&route.provider)
14704        }
14705        NodeRequest::PreviewNativeSession { selection, .. }
14706        | NodeRequest::IndexNativeSession { selection, .. } => {
14707            !provider_id_is_legacy(&selection.route.provider)
14708        }
14709        NodeRequest::SpawnSpec { .. }
14710        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14711        NodeRequest::SpawnManagedWorktree { .. }
14712        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14713        NodeRequest::Resume { session, .. }
14714        | NodeRequest::Prompt { session, .. }
14715        | NodeRequest::Paste { session, .. }
14716        | NodeRequest::Input { session, .. }
14717        | NodeRequest::TerminalBytes { session, .. }
14718        | NodeRequest::TerminalControl { session, .. }
14719        | NodeRequest::Resize { session, .. }
14720        | NodeRequest::Interrupt { session }
14721        | NodeRequest::Stop { session, .. }
14722        | NodeRequest::Remove { session }
14723        | NodeRequest::ResolveInteraction { session, .. }
14724        | NodeRequest::SetSessionMode { session, .. }
14725        | NodeRequest::SetSessionConfigOption { session, .. }
14726        | NodeRequest::SetSessionModel { session, .. }
14727        | NodeRequest::DiscoverHistory { session, .. }
14728        | NodeRequest::LoadHistory { session, .. }
14729        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14730        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14731            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14732        NodeRequest::RenameSessionRecord { record_id, .. }
14733        | NodeRequest::SetSessionTask { record_id, .. }
14734        | NodeRequest::ResumeSessionRecord { record_id, .. }
14735        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14736            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14737        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14738            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14739        NodeRequest::ArmHarnessMcpReservation { .. }
14740        | NodeRequest::ActivateHarnessMcpReservation { .. }
14741        | NodeRequest::AbortHarnessMcpReservation { .. }
14742        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14743        | NodeRequest::RejectHarnessMcpCall { .. }
14744        | NodeRequest::Snapshot
14745        | NodeRequest::Resync { .. }
14746        | NodeRequest::BeginDeliveryStage { .. }
14747        | NodeRequest::PutDeliveryBlobChunk { .. }
14748        | NodeRequest::CommitDeliveryStage { .. }
14749        | NodeRequest::AbortDeliveryStage { .. }
14750        | NodeRequest::BrowseHostDirectories { .. }
14751        | NodeRequest::InspectWorkspace { .. }
14752        | NodeRequest::ReadWorkspaceFile { .. }
14753        | NodeRequest::WriteWorkspaceFile { .. }
14754        | NodeRequest::CreateWorkspaceFile { .. }
14755        | NodeRequest::CreateWorkspaceDirectory { .. }
14756        | NodeRequest::ReadGitHistory { .. }
14757        | NodeRequest::ReadGitDiff { .. }
14758        | NodeRequest::AcquireController { .. }
14759        | NodeRequest::ReleaseController
14760        | NodeRequest::RegisterWorkspace { .. }
14761        | NodeRequest::CreateStandaloneWorkspace { .. }
14762        | NodeRequest::UnregisterWorkspace { .. }
14763        | NodeRequest::CreateWorktree { .. }
14764        | NodeRequest::RemoveWorktree { .. }
14765        | NodeRequest::CleanupManagedWorktree { .. }
14766        | NodeRequest::Shutdown => false,
14767        NodeRequest::ForgetContextPack { .. } => true,
14768        // No session/record to cheaply resolve a provider from here either;
14769        // the resolved receipt can carry an open-id provider, so require the
14770        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
14771        // is the same digest-keyed lookup one step further into the same
14772        // catalog, so it inherits the same conservative answer.
14773        NodeRequest::ResolveDurableContextPack { .. }
14774        | NodeRequest::ReadContextPack { .. } => true,
14775    }
14776}
14777
14778fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14779    snapshot.enabled_providers.retain(provider_id_is_legacy);
14780    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14781        snapshot
14782            .provider_runtime_statuses
14783            .as_slice()
14784            .iter()
14785            .filter(|status| provider_id_is_legacy(status.provider()))
14786            .cloned(),
14787    )
14788    .expect("a filtered provider runtime projection remains valid");
14789    snapshot
14790        .session_records
14791        .retain(|record| provider_id_is_legacy(&record.provider));
14792    for workspace in &mut snapshot.workspaces {
14793        workspace
14794            .sessions
14795            .retain(|session| provider_id_is_legacy(&session.agent_id));
14796    }
14797}
14798
14799fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14800    for record in &mut snapshot.session_records {
14801        record.environment_profile = None;
14802    }
14803    if let Some(profiles) = snapshot
14804        .launch_inventory
14805        .as_mut()
14806        .and_then(|inventory| inventory.spawn_profiles.as_mut())
14807    {
14808        for profile in profiles {
14809            profile.environment_profile = None;
14810        }
14811    }
14812}
14813
14814fn project_event_without_child_environment_profile(
14815    mut envelope: NodeEventEnvelope,
14816) -> NodeEventEnvelope {
14817    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14818        record.environment_profile = None;
14819    }
14820    envelope
14821}
14822
14823fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14824    let contains_environment_profile = match reply.result.as_ref() {
14825        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14826            receipt.environment_profile.is_some()
14827        }
14828        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14829            receipt.spawn.environment_profile.is_some()
14830        }
14831        _ => false,
14832    };
14833    if contains_environment_profile {
14834        reply.result = Err(failure(
14835            NodeFailureCode::UnsupportedCapability,
14836            "child environment profiles were not negotiated",
14837        ));
14838        return;
14839    }
14840    let Ok(response) = reply.result.as_mut() else {
14841        return;
14842    };
14843    match response {
14844        NodeResponse::Snapshot { snapshot, .. } => {
14845            clear_snapshot_child_environment_profiles(snapshot);
14846        }
14847        NodeResponse::Resync { snapshot, events, .. } => {
14848            clear_snapshot_child_environment_profiles(snapshot);
14849            for event in events {
14850                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14851                    record.environment_profile = None;
14852                }
14853            }
14854        }
14855        NodeResponse::SessionRecordUpdated { record }
14856        | NodeResponse::ProviderSessionIndexed { record }
14857        | NodeResponse::NativeSessionIndexed { record, .. }
14858        | NodeResponse::SessionRecordResumed { record, .. } => {
14859            record.environment_profile = None;
14860        }
14861        NodeResponse::Armed { .. }
14862        | NodeResponse::Spawned { .. }
14863        | NodeResponse::Activated { .. }
14864        | NodeResponse::Aborted { .. }
14865        | NodeResponse::ReplyChunkAccepted { .. }
14866        | NodeResponse::CallRejected { .. }
14867        | NodeResponse::DeliveryStageBegun { .. }
14868        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14869        | NodeResponse::DeliveryCommitted { .. }
14870        | NodeResponse::DeliveryStageAborted { .. }
14871        | NodeResponse::WorkspaceInspected { .. }
14872        | NodeResponse::HostDirectoriesBrowsed { .. }
14873        | NodeResponse::WorkspaceFileRead { .. }
14874        | NodeResponse::WorkspaceFileWritten { .. }
14875        | NodeResponse::WorkspaceFileCreated { .. }
14876        | NodeResponse::WorkspaceDirectoryCreated { .. }
14877        | NodeResponse::GitHistoryRead { .. }
14878        | NodeResponse::GitDiffRead { .. }
14879        | NodeResponse::Controller { .. }
14880        | NodeResponse::SpawnAccepted { .. }
14881        | NodeResponse::SpawnSpecAccepted { .. }
14882        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14883        | NodeResponse::ManagedWorktreeCleanup { .. }
14884        | NodeResponse::NativeSessionsCataloged { .. }
14885        | NodeResponse::NativeSessionsPaged { .. }
14886        | NodeResponse::NativeSessionPreviewed { .. }
14887        | NodeResponse::SessionRecordPreviewed { .. }
14888        | NodeResponse::HistoryDiscovered { .. }
14889        | NodeResponse::HistoryLoaded { .. }
14890        | NodeResponse::ContextPackForSessionRecordExported { .. }
14891        | NodeResponse::ContextPackExported { .. }
14892        | NodeResponse::ContextPackForgotten { .. }
14893        | NodeResponse::DurableContextPackResolved { .. }
14894        | NodeResponse::ContextPackBytesRead { .. }
14895        | NodeResponse::SessionRecordForgotten { .. }
14896        | NodeResponse::WorkspaceRegistered { .. }
14897        | NodeResponse::StandaloneWorkspaceCreated { .. }
14898        | NodeResponse::WorkspaceUnregistered { .. }
14899        | NodeResponse::WorktreeCreated { .. }
14900        | NodeResponse::WorktreeRemoved { .. }
14901        | NodeResponse::Accepted
14902        | NodeResponse::ShuttingDown => {}
14903    }
14904}
14905
14906fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14907    for record in &mut snapshot.session_records {
14908        record.bundle = None;
14909    }
14910}
14911
14912fn project_event_without_session_bundle(
14913    mut envelope: NodeEventEnvelope,
14914) -> NodeEventEnvelope {
14915    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14916        record.bundle = None;
14917    }
14918    envelope
14919}
14920
14921fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14922    let contains_bundle = match reply.result.as_ref() {
14923        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14924        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14925            receipt.spawn.bundle.is_some()
14926        }
14927        _ => false,
14928    };
14929    if contains_bundle {
14930        reply.result = Err(failure(
14931            NodeFailureCode::UnsupportedCapability,
14932            "session bundles were not negotiated",
14933        ));
14934        return;
14935    }
14936    let Ok(response) = reply.result.as_mut() else {
14937        return;
14938    };
14939    match response {
14940        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14941        NodeResponse::Resync { snapshot, events, .. } => {
14942            clear_snapshot_session_bundles(snapshot);
14943            for event in events {
14944                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14945                    record.bundle = None;
14946                }
14947            }
14948        }
14949        NodeResponse::SessionRecordUpdated { record }
14950        | NodeResponse::ProviderSessionIndexed { record }
14951        | NodeResponse::NativeSessionIndexed { record, .. }
14952        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14953        NodeResponse::Armed { .. }
14954        | NodeResponse::Spawned { .. }
14955        | NodeResponse::Activated { .. }
14956        | NodeResponse::Aborted { .. }
14957        | NodeResponse::ReplyChunkAccepted { .. }
14958        | NodeResponse::CallRejected { .. }
14959        | NodeResponse::DeliveryStageBegun { .. }
14960        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14961        | NodeResponse::DeliveryCommitted { .. }
14962        | NodeResponse::DeliveryStageAborted { .. }
14963        | NodeResponse::WorkspaceInspected { .. }
14964        | NodeResponse::HostDirectoriesBrowsed { .. }
14965        | NodeResponse::WorkspaceFileRead { .. }
14966        | NodeResponse::WorkspaceFileWritten { .. }
14967        | NodeResponse::WorkspaceFileCreated { .. }
14968        | NodeResponse::WorkspaceDirectoryCreated { .. }
14969        | NodeResponse::GitHistoryRead { .. }
14970        | NodeResponse::GitDiffRead { .. }
14971        | NodeResponse::Controller { .. }
14972        | NodeResponse::SpawnAccepted { .. }
14973        | NodeResponse::SpawnSpecAccepted { .. }
14974        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14975        | NodeResponse::ManagedWorktreeCleanup { .. }
14976        | NodeResponse::NativeSessionsCataloged { .. }
14977        | NodeResponse::NativeSessionsPaged { .. }
14978        | NodeResponse::NativeSessionPreviewed { .. }
14979        | NodeResponse::SessionRecordPreviewed { .. }
14980        | NodeResponse::HistoryDiscovered { .. }
14981        | NodeResponse::HistoryLoaded { .. }
14982        | NodeResponse::ContextPackForSessionRecordExported { .. }
14983        | NodeResponse::ContextPackExported { .. }
14984        | NodeResponse::ContextPackForgotten { .. }
14985        | NodeResponse::DurableContextPackResolved { .. }
14986        | NodeResponse::ContextPackBytesRead { .. }
14987        | NodeResponse::SessionRecordForgotten { .. }
14988        | NodeResponse::WorkspaceRegistered { .. }
14989        | NodeResponse::StandaloneWorkspaceCreated { .. }
14990        | NodeResponse::WorkspaceUnregistered { .. }
14991        | NodeResponse::WorktreeCreated { .. }
14992        | NodeResponse::WorktreeRemoved { .. }
14993        | NodeResponse::Accepted
14994        | NodeResponse::ShuttingDown => {}
14995    }
14996}
14997
14998fn project_snapshot_history_for_wire(
14999    snapshot: &mut NodeSnapshot,
15000    include_history_metadata: bool,
15001) {
15002    for workspace in &mut snapshot.workspaces {
15003        for session in &mut workspace.sessions {
15004            if include_history_metadata {
15005                session.history.loaded = None;
15006            } else {
15007                session.history = Default::default();
15008            }
15009        }
15010    }
15011}
15012
15013fn project_event_history_for_wire(
15014    mut envelope: NodeEventEnvelope,
15015    include_history_metadata: bool,
15016) -> NodeEventEnvelope {
15017    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15018        for session in &mut workspace.sessions {
15019            if include_history_metadata {
15020                session.history.loaded = None;
15021            } else {
15022                session.history = Default::default();
15023            }
15024        }
15025    }
15026    envelope
15027}
15028
15029fn project_response_history_for_wire(
15030    reply: &mut ResponseEnvelope,
15031    include_history_metadata: bool,
15032) {
15033    let Ok(response) = reply.result.as_mut() else {
15034        return;
15035    };
15036    match response {
15037        NodeResponse::Snapshot { snapshot, .. } => {
15038            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15039        }
15040        NodeResponse::Resync {
15041            snapshot, events, ..
15042        } => {
15043            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15044            for event in events {
15045                *event = project_event_history_for_wire(
15046                    event.clone(),
15047                    include_history_metadata,
15048                );
15049            }
15050        }
15051        NodeResponse::WorkspaceRegistered { workspace }
15052        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15053        | NodeResponse::WorktreeCreated { workspace, .. } => {
15054            for session in &mut workspace.sessions {
15055                if include_history_metadata {
15056                    session.history.loaded = None;
15057                } else {
15058                    session.history = Default::default();
15059                }
15060            }
15061        }
15062        _ => {}
15063    }
15064}
15065
15066fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15067    for record in &mut snapshot.session_records {
15068        record.context_id = None;
15069        record.context = None;
15070        record.exported_context = None;
15071    }
15072}
15073
15074fn project_event_without_context_pack(
15075    mut envelope: NodeEventEnvelope,
15076) -> NodeEventEnvelope {
15077    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15078        record.context_id = None;
15079        record.context = None;
15080        record.exported_context = None;
15081    }
15082    envelope
15083}
15084
15085fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15086    let contains_context = match reply.result.as_ref() {
15087        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15088        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15089            receipt.spawn.context.is_some()
15090        }
15091        Ok(NodeResponse::HistoryDiscovered { .. })
15092        | Ok(NodeResponse::HistoryLoaded { .. })
15093        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15094        | Ok(NodeResponse::ContextPackExported { .. })
15095        | Ok(NodeResponse::ContextPackForgotten { .. })
15096        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15097        _ => false,
15098    };
15099    if contains_context {
15100        reply.result = Err(failure(
15101            NodeFailureCode::UnsupportedCapability,
15102            "history context packs were not negotiated",
15103        ));
15104        return;
15105    }
15106    let Ok(response) = reply.result.as_mut() else {
15107        return;
15108    };
15109    match response {
15110        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15111        NodeResponse::Resync {
15112            snapshot, events, ..
15113        } => {
15114            clear_snapshot_context_packs(snapshot);
15115            for event in events {
15116                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15117                    record.context_id = None;
15118                    record.context = None;
15119                    record.exported_context = None;
15120                }
15121            }
15122        }
15123        NodeResponse::SessionRecordUpdated { record }
15124        | NodeResponse::ProviderSessionIndexed { record }
15125        | NodeResponse::NativeSessionIndexed { record, .. }
15126        | NodeResponse::SessionRecordResumed { record, .. } => {
15127            record.context_id = None;
15128            record.context = None;
15129            record.exported_context = None;
15130        }
15131        _ => {}
15132    }
15133}
15134
15135fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15136    let contains_open_record = match reply.result.as_ref() {
15137        Ok(NodeResponse::SessionRecordUpdated { record })
15138        | Ok(NodeResponse::ProviderSessionIndexed { record })
15139        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15140            !provider_id_is_legacy(&record.provider)
15141        }
15142        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15143            !provider_id_is_legacy(&selection.route.provider)
15144                || !provider_id_is_legacy(&record.provider)
15145        }
15146        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15147            !provider_id_is_legacy(&receipt.provider)
15148        }
15149        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15150            !provider_id_is_legacy(&receipt.spawn.provider)
15151        }
15152        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15153        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15154            !provider_id_is_legacy(&route.provider)
15155        }
15156        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15157            !provider_id_is_legacy(&selection.route.provider)
15158        }
15159        _ => false,
15160    };
15161    if contains_open_record {
15162        reply.result = Err(failure(
15163            NodeFailureCode::UnsupportedCapability,
15164            "open provider IDs were not negotiated",
15165        ));
15166        return;
15167    }
15168    let Ok(response) = reply.result.as_mut() else {
15169        return;
15170    };
15171    match response {
15172        NodeResponse::Snapshot { snapshot, .. } => {
15173            project_snapshot_legacy_provider_ids(snapshot);
15174        }
15175        NodeResponse::Resync { snapshot, events, .. } => {
15176            project_snapshot_legacy_provider_ids(snapshot);
15177            *events = std::mem::take(events)
15178                .into_iter()
15179                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15180                .collect();
15181        }
15182        NodeResponse::WorkspaceRegistered { workspace }
15183        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15184        | NodeResponse::WorktreeCreated { workspace, .. } => {
15185            workspace
15186                .sessions
15187                .retain(|session| provider_id_is_legacy(&session.agent_id));
15188        }
15189        NodeResponse::Armed { .. }
15190        | NodeResponse::Spawned { .. }
15191        | NodeResponse::Activated { .. }
15192        | NodeResponse::Aborted { .. }
15193        | NodeResponse::ReplyChunkAccepted { .. }
15194        | NodeResponse::CallRejected { .. }
15195        | NodeResponse::DeliveryStageBegun { .. }
15196        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15197        | NodeResponse::DeliveryCommitted { .. }
15198        | NodeResponse::DeliveryStageAborted { .. }
15199        | NodeResponse::WorkspaceInspected { .. }
15200        | NodeResponse::HostDirectoriesBrowsed { .. }
15201        | NodeResponse::WorkspaceFileRead { .. }
15202        | NodeResponse::WorkspaceFileWritten { .. }
15203        | NodeResponse::WorkspaceFileCreated { .. }
15204        | NodeResponse::WorkspaceDirectoryCreated { .. }
15205        | NodeResponse::GitHistoryRead { .. }
15206        | NodeResponse::GitDiffRead { .. }
15207        | NodeResponse::Controller { .. }
15208        | NodeResponse::SpawnAccepted { .. }
15209        | NodeResponse::SpawnSpecAccepted { .. }
15210        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15211        | NodeResponse::ManagedWorktreeCleanup { .. }
15212        | NodeResponse::NativeSessionsCataloged { .. }
15213        | NodeResponse::NativeSessionsPaged { .. }
15214        | NodeResponse::NativeSessionPreviewed { .. }
15215        | NodeResponse::SessionRecordPreviewed { .. }
15216        | NodeResponse::HistoryDiscovered { .. }
15217        | NodeResponse::HistoryLoaded { .. }
15218        | NodeResponse::ContextPackForSessionRecordExported { .. }
15219        | NodeResponse::ContextPackExported { .. }
15220        | NodeResponse::ContextPackForgotten { .. }
15221        | NodeResponse::DurableContextPackResolved { .. }
15222        | NodeResponse::ContextPackBytesRead { .. }
15223        | NodeResponse::SessionRecordUpdated { .. }
15224        | NodeResponse::ProviderSessionIndexed { .. }
15225        | NodeResponse::NativeSessionIndexed { .. }
15226        | NodeResponse::SessionRecordResumed { .. }
15227        | NodeResponse::SessionRecordForgotten { .. }
15228        | NodeResponse::WorkspaceUnregistered { .. }
15229        | NodeResponse::WorktreeRemoved { .. }
15230        | NodeResponse::Accepted
15231        | NodeResponse::ShuttingDown => {}
15232    }
15233}
15234
15235fn project_event_legacy_provider_ids(
15236    shared: &NodeShared,
15237    mut envelope: NodeEventEnvelope,
15238) -> Option<NodeEventEnvelope> {
15239    let include = match &mut envelope.event {
15240        NodeEvent::HarnessMcpReadCall { .. } => true,
15241        NodeEvent::Control { address, .. }
15242        | NodeEvent::AgentStream { address, .. }
15243        | NodeEvent::TerminalFrame { address, .. } => shared
15244            .validate_address(address)
15245            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15246        NodeEvent::WorkspaceAdded { workspace } => {
15247            workspace
15248                .sessions
15249                .retain(|session| provider_id_is_legacy(&session.agent_id));
15250            true
15251        }
15252        NodeEvent::SessionRecordUpserted { record } => {
15253            provider_id_is_legacy(&record.provider)
15254        }
15255        NodeEvent::SessionRecordHistorySummarized { .. } => false,
15256        NodeEvent::SessionRecordRemoved { .. } => shared
15257            .history
15258            .lock()
15259            .unwrap_or_else(|poisoned| poisoned.into_inner())
15260            .removed_record_providers
15261            .get(&envelope.sequence)
15262            .is_some_and(provider_id_is_legacy),
15263        NodeEvent::ControllerChanged { .. }
15264        | NodeEvent::ManagedWorktreeUpserted { .. }
15265        | NodeEvent::ManagedWorktreeRemoved { .. }
15266        | NodeEvent::WorkspaceRemoved { .. }
15267        | NodeEvent::ResyncRequired { .. } => true,
15268    };
15269    include.then_some(envelope)
15270}
15271
15272fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15273    let Ok(response) = reply.result.as_mut() else {
15274        return;
15275    };
15276    match response {
15277        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15278            snapshot.provider_runtime_statuses.clear();
15279        }
15280        NodeResponse::Armed { .. }
15281        | NodeResponse::Spawned { .. }
15282        | NodeResponse::Activated { .. }
15283        | NodeResponse::Aborted { .. }
15284        | NodeResponse::ReplyChunkAccepted { .. }
15285        | NodeResponse::CallRejected { .. }
15286        | NodeResponse::DeliveryStageBegun { .. }
15287        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15288        | NodeResponse::DeliveryCommitted { .. }
15289        | NodeResponse::DeliveryStageAborted { .. }
15290        | NodeResponse::WorkspaceInspected { .. }
15291        | NodeResponse::HostDirectoriesBrowsed { .. }
15292        | NodeResponse::WorkspaceFileRead { .. }
15293        | NodeResponse::WorkspaceFileWritten { .. }
15294        | NodeResponse::WorkspaceFileCreated { .. }
15295        | NodeResponse::WorkspaceDirectoryCreated { .. }
15296        | NodeResponse::GitHistoryRead { .. }
15297        | NodeResponse::GitDiffRead { .. }
15298        | NodeResponse::Controller { .. }
15299        | NodeResponse::SpawnAccepted { .. }
15300        | NodeResponse::SpawnSpecAccepted { .. }
15301        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15302        | NodeResponse::ManagedWorktreeCleanup { .. }
15303        | NodeResponse::NativeSessionsCataloged { .. }
15304        | NodeResponse::NativeSessionsPaged { .. }
15305        | NodeResponse::NativeSessionPreviewed { .. }
15306        | NodeResponse::SessionRecordPreviewed { .. }
15307        | NodeResponse::HistoryDiscovered { .. }
15308        | NodeResponse::HistoryLoaded { .. }
15309        | NodeResponse::ContextPackForSessionRecordExported { .. }
15310        | NodeResponse::ContextPackExported { .. }
15311        | NodeResponse::ContextPackForgotten { .. }
15312        | NodeResponse::DurableContextPackResolved { .. }
15313        | NodeResponse::ContextPackBytesRead { .. }
15314        | NodeResponse::SessionRecordUpdated { .. }
15315        | NodeResponse::ProviderSessionIndexed { .. }
15316        | NodeResponse::NativeSessionIndexed { .. }
15317        | NodeResponse::SessionRecordResumed { .. }
15318        | NodeResponse::SessionRecordForgotten { .. }
15319        | NodeResponse::WorkspaceRegistered { .. }
15320        | NodeResponse::StandaloneWorkspaceCreated { .. }
15321        | NodeResponse::WorkspaceUnregistered { .. }
15322        | NodeResponse::WorktreeCreated { .. }
15323        | NodeResponse::WorktreeRemoved { .. }
15324        | NodeResponse::Accepted
15325        | NodeResponse::ShuttingDown => {}
15326    }
15327}
15328
15329struct ControllerReleaseGuard {
15330    shared: Arc<NodeShared>,
15331    connection_id: u64,
15332}
15333
15334impl Drop for ControllerReleaseGuard {
15335    fn drop(&mut self) {
15336        self.shared.release_controller(self.connection_id);
15337    }
15338}
15339
15340struct AbortTaskOnDrop(AbortHandle);
15341
15342impl Drop for AbortTaskOnDrop {
15343    fn drop(&mut self) {
15344        self.0.abort();
15345    }
15346}
15347
15348/// Reads an unsigned env-config override, following the pattern of every
15349/// other bounded knob in this module: missing/unparsable/zero falls back to
15350/// `default`, and the resolved value is always clamped to `max` so a bad
15351/// override cannot reopen a bound this budget exists to enforce.
15352fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15353    std::env::var(name)
15354        .ok()
15355        .and_then(|value| value.parse::<u64>().ok())
15356        .filter(|value| *value > 0)
15357        .unwrap_or(default)
15358        .min(max)
15359}
15360
15361fn workspace_inspection_time_budget_ms() -> u64 {
15362    env_config_bounded(
15363        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15364        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15365        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15366    )
15367}
15368
15369fn workspace_inspection_entry_cap() -> usize {
15370    env_config_bounded(
15371        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15372        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15373        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15374    ) as usize
15375}
15376
15377/// Tracks the shared walk-side budget across the whole recursive walk: a
15378/// wall-clock deadline (shared with the git phase that runs after the walk)
15379/// and a cap on directory entries visited, whether or not they were pushed
15380/// to the response. Cheap to check per entry — one `Instant::now()` and one
15381/// integer compare.
15382struct WorkspaceWalkBudget {
15383    deadline: Instant,
15384    entry_cap: usize,
15385    entries_visited: u64,
15386    time_budget_exceeded: bool,
15387    entry_cap_exceeded: bool,
15388}
15389
15390impl WorkspaceWalkBudget {
15391    fn new(deadline: Instant, entry_cap: usize) -> Self {
15392        Self {
15393            deadline,
15394            entry_cap,
15395            entries_visited: 0,
15396            time_budget_exceeded: false,
15397            entry_cap_exceeded: false,
15398        }
15399    }
15400
15401    fn is_exhausted(&self) -> bool {
15402        self.time_budget_exceeded || self.entry_cap_exceeded
15403    }
15404
15405    /// Records one visited directory entry (pushed to the response or not)
15406    /// and reports whether the walk must stop now.
15407    fn record_visit(&mut self) -> bool {
15408        self.entries_visited += 1;
15409        if self.entries_visited as usize >= self.entry_cap {
15410            self.entry_cap_exceeded = true;
15411        }
15412        if Instant::now() >= self.deadline {
15413            self.time_budget_exceeded = true;
15414        }
15415        self.is_exhausted()
15416    }
15417}
15418
15419fn collect_workspace_entries(
15420    root: &Path,
15421    deadline: Instant,
15422    entry_cap: usize,
15423) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15424    let mut entries = Vec::new();
15425    let mut truncated = false;
15426    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15427    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15428    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15429    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15430    // require strict global byte order over relative paths.
15431    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15432    (entries, truncated, budget)
15433}
15434
15435/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15436/// `inspect_workspace` response — `None` once the walk and git phase both
15437/// completed inside their shared budget.
15438fn workspace_inspection_truncation(
15439    walk_budget: &WorkspaceWalkBudget,
15440    git_time_budget_exceeded: bool,
15441    elapsed: Duration,
15442) -> Option<WorkspaceInspectionTruncationV1> {
15443    if !walk_budget.time_budget_exceeded
15444        && !walk_budget.entry_cap_exceeded
15445        && !git_time_budget_exceeded
15446    {
15447        return None;
15448    }
15449    Some(WorkspaceInspectionTruncationV1 {
15450        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15451        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15452        git_time_budget_exceeded,
15453        entries_visited: walk_budget.entries_visited,
15454        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15455    })
15456}
15457
15458fn walk_workspace_directory(
15459    directory: &Path,
15460    relative_directory: Option<&RepositoryPath>,
15461    depth: usize,
15462    entries: &mut Vec<WorkspaceEntry>,
15463    truncated: &mut bool,
15464    budget: &mut WorkspaceWalkBudget,
15465) {
15466    if budget.is_exhausted() {
15467        *truncated = true;
15468        return;
15469    }
15470    let read_dir = match std::fs::read_dir(directory) {
15471        Ok(read_dir) => read_dir,
15472        Err(_) => {
15473            *truncated = true;
15474            return;
15475        }
15476    };
15477    let mut children = Vec::new();
15478    for child in read_dir {
15479        match child {
15480            Ok(child) => children.push(child),
15481            Err(_) => *truncated = true,
15482        }
15483    }
15484    children.sort_by_key(|child| child.file_name());
15485    for child in children {
15486        if budget.record_visit() {
15487            *truncated = true;
15488            return;
15489        }
15490        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15491            *truncated = true;
15492            return;
15493        }
15494        let file_type = match child.file_type() {
15495            Ok(file_type) => file_type,
15496            Err(_) => {
15497                *truncated = true;
15498                continue;
15499            }
15500        };
15501        if file_type.is_symlink() {
15502            continue;
15503        }
15504        let name = match child.file_name().into_string() {
15505            Ok(name) => name,
15506            Err(_) => {
15507                *truncated = true;
15508                continue;
15509            }
15510        };
15511        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15512            continue;
15513        }
15514        let relative_path = match relative_directory {
15515            None => windows_repository_path(name),
15516            Some(parent) => parent
15517                .as_utf8()
15518                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15519        };
15520        let Some(relative_path) = relative_path else {
15521            *truncated = true;
15522            continue;
15523        };
15524        let kind = if file_type.is_dir() {
15525            WorkspaceEntryKind::Directory
15526        } else if file_type.is_file() {
15527            WorkspaceEntryKind::File
15528        } else {
15529            continue;
15530        };
15531        entries.push(WorkspaceEntry {
15532            relative_path: relative_path.clone(),
15533            kind,
15534        });
15535        if file_type.is_dir() {
15536            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15537                walk_workspace_directory(
15538                    &child.path(),
15539                    Some(&relative_path),
15540                    depth + 1,
15541                    entries,
15542                    truncated,
15543                    budget,
15544                );
15545            } else {
15546                *truncated = true;
15547            }
15548        }
15549    }
15550}
15551
15552fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15553    if value.contains('\\') {
15554        return None;
15555    }
15556    RepositoryPath::utf8(value).ok()
15557}
15558
15559fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15560    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15561}
15562
15563/// Excludes a directory from the workspace tree walk. The three literal
15564/// names are the common convention-based cases; the `CACHEDIR.TAG` check
15565/// beneath them catches every build-output directory regardless of name
15566/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
15567/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
15568/// directory without maintaining a name heuristic list). The name check
15569/// runs first and returns early so a directory rejected by name never pays
15570/// for the extra `CACHEDIR.TAG` stat.
15571fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15572    if name.eq_ignore_ascii_case(".git")
15573        || name.eq_ignore_ascii_case("target")
15574        || name.eq_ignore_ascii_case("node_modules")
15575    {
15576        return true;
15577    }
15578    path.join("CACHEDIR.TAG").is_file()
15579}
15580
15581fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15582    matches!(
15583        status,
15584        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15585    )
15586}
15587
15588#[derive(Clone, Debug, Eq, PartialEq)]
15589enum ContextPackRepositoryHead {
15590    NotRepository,
15591    Unborn,
15592    Commit(String),
15593}
15594
15595#[derive(Clone, Debug, Eq, PartialEq)]
15596struct ContextPackRepositoryObservation {
15597    head: ContextPackRepositoryHead,
15598    git: GitSnapshot,
15599    files: Vec<ContextPackRepositoryFileSource>,
15600}
15601
15602fn stable_context_pack_repository(
15603    first: ContextPackRepositoryObservation,
15604    second: ContextPackRepositoryObservation,
15605) -> Result<ContextPackRepository, NodeFailure> {
15606    if first != second {
15607        return Err(failure(
15608            NodeFailureCode::ContextPackMaterializationFailed,
15609            "context repository changed during bounded capture",
15610        ));
15611    }
15612    Ok(ContextPackRepository::from_sources(
15613        &second.git,
15614        second.files,
15615    ))
15616}
15617
15618/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
15619/// (and only once) the shared walk+git `deadline` has already passed. Every
15620/// remaining phase in `inspect_git_workspace` checks this immediately
15621/// before issuing its own git call, so a budget spent by the walk (or by an
15622/// earlier git call) skips the rest of the chain instead of paying for it
15623/// anyway.
15624fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15625    if Instant::now() < deadline {
15626        return false;
15627    }
15628    append_git_diagnostic(
15629        snapshot,
15630        &format!("{stage} skipped: workspace inspection time budget exhausted"),
15631    );
15632    true
15633}
15634
15635/// Deadline-aware sibling of `run_git_bounded`, used only by
15636/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
15637/// whatever remains of the shared `deadline` (never more than
15638/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
15639/// of the budget cannot still hand a full per-call timeout to every git
15640/// probe behind it. `inspect_git_workspace` already guards each call site
15641/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
15642/// passed yet here.
15643async fn run_git_bounded_with_deadline(
15644    root: &str,
15645    arguments: &[&str],
15646    output_limit: usize,
15647    deadline: Instant,
15648) -> io::Result<GitCommandOutput> {
15649    let remaining = deadline.checked_duration_since(Instant::now());
15650    let timeout_ms = remaining
15651        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15652        .unwrap_or(0)
15653        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15654    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15655}
15656
15657/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
15658/// `deadline` is the same absolute instant the preceding directory walk was
15659/// bounded by, so time already spent walking counts against what remains
15660/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
15661/// shared budget (not an individual per-call timeout) is what cut the git
15662/// phase short.
15663async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15664    let mut snapshot = GitSnapshot {
15665        is_repository: false,
15666        branch: None,
15667        status: Vec::new(),
15668        recent_commits: Vec::new(),
15669        worktrees: Vec::new(),
15670        managed_worktree: None,
15671        truncated: false,
15672        diagnostic: None,
15673    };
15674    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15675        return (snapshot, true);
15676    }
15677    let repository = match run_git_bounded_with_deadline(
15678        root,
15679        &["rev-parse", "--is-inside-work-tree"],
15680        4 * 1_024,
15681        deadline,
15682    )
15683    .await
15684    {
15685        Ok(output) => output,
15686        Err(error) => {
15687            append_git_diagnostic(
15688                &mut snapshot,
15689                &format!("git inspection unavailable: {error}"),
15690            );
15691            return (snapshot, false);
15692        }
15693    };
15694    snapshot.truncated |= repository.truncated;
15695    if repository.timed_out {
15696        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15697        return (snapshot, false);
15698    }
15699    if !repository.success
15700        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15701    {
15702        let stderr = String::from_utf8_lossy(&repository.stderr);
15703        if !stderr.to_ascii_lowercase().contains("not a git repository") {
15704            append_git_diagnostic(&mut snapshot, stderr.trim());
15705        }
15706        return (snapshot, false);
15707    }
15708    snapshot.is_repository = true;
15709
15710    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15711        return (snapshot, true);
15712    }
15713    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15714        Ok(output) => {
15715            snapshot.truncated |= output.truncated;
15716            if output.timed_out {
15717                append_git_diagnostic(&mut snapshot, "git branch query timed out");
15718            } else if output.success {
15719                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15720                if !branch.is_empty() {
15721                    snapshot.branch = Some(truncate_git_field(
15722                        &branch,
15723                        MAX_REPOSITORY_PATH_BYTES,
15724                        &mut snapshot.truncated,
15725                    ));
15726                }
15727            }
15728        }
15729        Err(error) => append_git_diagnostic(
15730            &mut snapshot,
15731            &format!("git branch query failed: {error}"),
15732        ),
15733    }
15734    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15735        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15736            snapshot.truncated |= output.truncated;
15737            if output.success && !output.timed_out {
15738                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15739                if !head.is_empty() {
15740                    snapshot.branch = Some(format!("detached:{head}"));
15741                }
15742            }
15743        }
15744    }
15745
15746    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15747        return (snapshot, true);
15748    }
15749    match run_git_bounded_with_deadline(
15750        root,
15751        &[
15752            "status",
15753            "--porcelain=v1",
15754            "-z",
15755            "--renames",
15756            "--untracked-files=normal",
15757            "--",
15758            ".",
15759        ],
15760        GIT_OUTPUT_MAX_BYTES,
15761        deadline,
15762    )
15763    .await
15764    {
15765        Ok(output) => {
15766            snapshot.truncated |= output.truncated;
15767            if output.timed_out {
15768                append_git_diagnostic(&mut snapshot, "git status timed out");
15769            } else if output.success {
15770                parse_git_status(&output.stdout, &mut snapshot);
15771            } else {
15772                append_git_diagnostic(
15773                    &mut snapshot,
15774                    String::from_utf8_lossy(&output.stderr).trim(),
15775                );
15776            }
15777        }
15778        Err(error) => append_git_diagnostic(
15779            &mut snapshot,
15780            &format!("git status failed: {error}"),
15781        ),
15782    }
15783
15784    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15785        return (snapshot, true);
15786    }
15787    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15788    match run_git_bounded_with_deadline(
15789        root,
15790        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15791        16 * 1_024,
15792        deadline,
15793    )
15794    .await
15795    {
15796        Ok(output) => {
15797            snapshot.truncated |= output.truncated;
15798            if output.timed_out {
15799                append_git_diagnostic(&mut snapshot, "git log timed out");
15800            } else if output.success {
15801                parse_git_commits(&output.stdout, &mut snapshot);
15802            } else {
15803                let stderr = String::from_utf8_lossy(&output.stderr);
15804                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15805                    append_git_diagnostic(&mut snapshot, stderr.trim());
15806                }
15807            }
15808        }
15809        Err(error) => append_git_diagnostic(
15810            &mut snapshot,
15811            &format!("git log failed: {error}"),
15812        ),
15813    }
15814
15815    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15816        return (snapshot, true);
15817    }
15818    match list_git_worktrees_with_deadline(root, deadline).await {
15819        Ok(worktrees) => {
15820            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15821        }
15822        Err(error) => append_git_diagnostic(
15823            &mut snapshot,
15824            &format!("git worktree list failed: {}", error.message),
15825        ),
15826    }
15827    (snapshot, false)
15828}
15829
15830fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15831    let mut remaining = output;
15832    while !remaining.is_empty() {
15833        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15834            snapshot.truncated = true;
15835            break;
15836        }
15837        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15838            snapshot.truncated = true;
15839            break;
15840        };
15841        let record = &remaining[..record_end];
15842        remaining = &remaining[record_end + 1..];
15843        let has_previous_path = record
15844            .get(..2)
15845            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15846        let previous_path_bytes = if has_previous_path {
15847            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15848                snapshot.truncated = true;
15849                break;
15850            };
15851            let previous = &remaining[..previous_end];
15852            remaining = &remaining[previous_end + 1..];
15853            Some(previous)
15854        } else {
15855            None
15856        };
15857        if record.len() < 4
15858            || record[2] != b' '
15859            || !is_git_status_code(record[0])
15860            || !is_git_status_code(record[1])
15861        {
15862            snapshot.truncated = true;
15863            continue;
15864        }
15865        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15866            snapshot.truncated = true;
15867            continue;
15868        };
15869        let previous_path = match previous_path_bytes {
15870            Some(previous) => {
15871                let Some(previous) = windows_repository_path_from_bytes(previous) else {
15872                    snapshot.truncated = true;
15873                    continue;
15874                };
15875                Some(previous)
15876            }
15877            None => None,
15878        };
15879        snapshot.status.push(GitStatusEntry {
15880            index_status: (record[0] as char).to_string(),
15881            worktree_status: (record[1] as char).to_string(),
15882            path,
15883            previous_path,
15884        });
15885    }
15886}
15887
15888fn is_git_status_code(byte: u8) -> bool {
15889    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15890}
15891
15892fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15893    for line in String::from_utf8_lossy(output).lines() {
15894        let Some((id, summary)) = line.split_once('\u{1f}') else {
15895            continue;
15896        };
15897        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15898            snapshot.truncated = true;
15899            break;
15900        }
15901        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15902        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15903        snapshot.recent_commits.push(GitCommitSummary { id, summary });
15904    }
15905}
15906
15907fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15908    if value.len() <= max_bytes {
15909        return value.to_owned();
15910    }
15911    *truncated = true;
15912    let mut boundary = max_bytes;
15913    while boundary > 0 && !value.is_char_boundary(boundary) {
15914        boundary -= 1;
15915    }
15916    value[..boundary].to_owned()
15917}
15918
15919fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15920    if message.is_empty() {
15921        return;
15922    }
15923    let normalized = message
15924        .chars()
15925        .map(|character| if character.is_control() { ' ' } else { character })
15926        .collect::<String>();
15927    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15928    if !diagnostic.is_empty() {
15929        diagnostic.push_str("; ");
15930    }
15931    diagnostic.push_str(normalized.trim());
15932    snapshot.diagnostic = Some(truncate_git_field(
15933        &diagnostic,
15934        GIT_DIAGNOSTIC_MAX_BYTES,
15935        &mut snapshot.truncated,
15936    ));
15937}
15938
15939async fn run_git_bounded(
15940    root: &str,
15941    arguments: &[&str],
15942    output_limit: usize,
15943) -> io::Result<GitCommandOutput> {
15944    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15945}
15946
15947async fn read_git_history_bounded(
15948    root: &str,
15949    path: Option<&RepositoryPath>,
15950    before: Option<&GitObjectId>,
15951    limit: u16,
15952) -> Result<GitHistoryPage, NodeFailure> {
15953    let path = path
15954        .map(|path| {
15955            path.as_utf8().ok_or_else(|| {
15956                failure(
15957                    NodeFailureCode::InvalidRepositoryPath,
15958                    "git history path must use UTF-8",
15959                )
15960            })
15961        })
15962        .transpose()?
15963        .unwrap_or(".");
15964    let count = (usize::from(limit) + 1).to_string();
15965    let start = before
15966        .map(|revision| format!("{}^", revision.as_str()))
15967        .unwrap_or_else(|| "HEAD".to_owned());
15968    let arguments = [
15969        "--no-pager",
15970        "log",
15971        "--no-decorate",
15972        "--date=iso-strict",
15973        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15974        "-n",
15975        count.as_str(),
15976        start.as_str(),
15977        "--",
15978        path,
15979    ];
15980    let output = run_git_bounded(root, &arguments, 256 * 1_024)
15981        .await
15982        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15983    if output.timed_out {
15984        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15985    }
15986    if !output.success {
15987        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15988    }
15989    let mut commits = parse_git_history(&output.stdout);
15990    let has_more = commits.len() > usize::from(limit) || output.truncated;
15991    commits.truncate(usize::from(limit));
15992    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15993    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15994}
15995
15996fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15997    String::from_utf8_lossy(output)
15998        .split('\u{1e}')
15999        .filter_map(|record| {
16000            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16001            if fields.len() != 9 {
16002                return None;
16003            }
16004            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16005            let parents = if fields[1].is_empty() {
16006                Vec::new()
16007            } else {
16008                fields[1]
16009                    .split(' ')
16010                    .map(|value| GitObjectId::new(value.to_owned()))
16011                    .collect::<Result<Vec<_>, _>>()
16012                    .ok()?
16013            };
16014            Some(GitCommitDetails {
16015                id,
16016                parents,
16017                subject: bounded_git_text(fields[2], 512),
16018                author_name: bounded_git_text(fields[3], 256),
16019                author_email: bounded_git_text(fields[4], 320),
16020                authored_at: bounded_git_text(fields[5], 64),
16021                committer_name: bounded_git_text(fields[6], 256),
16022                committer_email: bounded_git_text(fields[7], 320),
16023                committed_at: bounded_git_text(fields[8], 64),
16024                signature_status: GitSignatureStatus::NoSignature,
16025                signer: None,
16026            })
16027        })
16028        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16029        .collect()
16030}
16031
16032fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16033    let normalized = value
16034        .chars()
16035        .map(|character| if character.is_control() { ' ' } else { character })
16036        .collect::<String>();
16037    if normalized.len() <= max_bytes {
16038        return normalized;
16039    }
16040    let mut boundary = max_bytes;
16041    while boundary > 0 && !normalized.is_char_boundary(boundary) {
16042        boundary -= 1;
16043    }
16044    normalized[..boundary].to_owned()
16045}
16046
16047async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16048    let path = request.path.as_ref().map(|path| {
16049        path.as_utf8()
16050            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16051    }).transpose()?;
16052    let mut arguments = match &request.mode {
16053        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16054        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16055        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16056    };
16057    if let GitDiffMode::Commit { revision } = &request.mode {
16058        arguments.push(revision.as_str());
16059    }
16060    arguments.push("--");
16061    if let Some(path) = path {
16062        arguments.push(path);
16063    }
16064    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16065        .await
16066        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16067    if output.timed_out {
16068        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16069    }
16070    if !output.success {
16071        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16072    }
16073    Ok(GitDiff {
16074        mode: request.mode,
16075        path: request.path,
16076        text: String::from_utf8_lossy(&output.stdout).into_owned(),
16077        truncated: output.truncated,
16078    })
16079}
16080
16081async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16082    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16083    ResponseEnvelope { request_id: envelope.request_id, result }
16084}
16085
16086async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16087    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16088        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16089    }
16090    let read_only = matches!(
16091        &request,
16092        NodeRequest::Snapshot
16093            | NodeRequest::Resync { .. }
16094            | NodeRequest::BrowseHostDirectories { .. }
16095            | NodeRequest::InspectWorkspace { .. }
16096            | NodeRequest::ReadWorkspaceFile { .. }
16097            | NodeRequest::ReadGitHistory { .. }
16098            | NodeRequest::ReadGitDiff { .. }
16099            | NodeRequest::CatalogNativeSessions { .. }
16100            | NodeRequest::PageNativeSessions { .. }
16101            | NodeRequest::PreviewNativeSession { .. }
16102            | NodeRequest::PreviewSessionRecord { .. }
16103    );
16104    let _mutation_guard = if read_only {
16105        None
16106    } else {
16107        let guard = shared.mutation_gate.lock().await;
16108        if shared.shutdown.load(Ordering::Acquire) {
16109            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16110        }
16111        Some(guard)
16112    };
16113    match request {
16114        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16115            event_sequence: shared.current_sequence(),
16116            controller: shared.controller_state(),
16117            snapshot: shared.snapshot(),
16118        }),
16119        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16120        NodeRequest::ArmHarnessMcpReservation {
16121            reservation_id,
16122            activation_digest,
16123            spawn_spec,
16124            launch,
16125            expires_at_unix_ms,
16126        } => {
16127            shared.require_controller(connection_id, role)?;
16128            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16129                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16130            })?;
16131            let expires_at_unix_ms = registry
16132                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16133                .await
16134                .map_err(harness_mcp_failure)?;
16135            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16136        }
16137        NodeRequest::SpawnSpecWithHarnessMcp {
16138            reservation_id,
16139            activation_digest,
16140            spec,
16141            deadline_unix_ms,
16142        } => {
16143            shared.require_controller(connection_id, role)?;
16144            let receipt = shared
16145                .spawn_from_spec_with_harness_mcp(
16146                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16147                )
16148                .await?;
16149            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16150        }
16151        NodeRequest::ActivateHarnessMcpReservation {
16152            reservation_id,
16153            activation_digest,
16154            record_id,
16155            session,
16156        } => {
16157            shared.require_controller(connection_id, role)?;
16158            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16159                current.instance_id == session.session.instance_id
16160                    && current.generation == session.session.generation
16161            }).and_then(|current| current.process_id).ok_or_else(|| {
16162                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16163            })?;
16164            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16165                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16166            })?.activate(
16167                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16168            ).map_err(harness_mcp_failure)?;
16169            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16170        }
16171        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16172            shared.require_controller(connection_id, role)?;
16173            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16174                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16175            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16176            if let Some(instance_id) = instance_id {
16177                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16178            }
16179            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16180        }
16181        NodeRequest::PutHarnessMcpReplyChunk {
16182            reservation_id, activation_digest, record_id, session, call_id,
16183            offset, final_chunk, chunk_hex,
16184        } => {
16185            shared.require_controller(connection_id, role)?;
16186            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16187                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16188            })?.put_reply_chunk(
16189                &reservation_id, &activation_digest, &record_id, &session, &call_id,
16190                offset, final_chunk, &chunk_hex,
16191            ).map_err(harness_mcp_failure)?;
16192            Ok(NodeResponse::ReplyChunkAccepted {
16193                reservation_id, activation_digest, record_id, session, call_id,
16194                next_offset, completed,
16195            })
16196        }
16197        NodeRequest::RejectHarnessMcpCall {
16198            reservation_id, activation_digest, record_id, session, call_id, reason,
16199        } => {
16200            shared.require_controller(connection_id, role)?;
16201            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16202                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16203            })?.reject_call(
16204                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16205            ).map_err(harness_mcp_failure)?;
16206            Ok(NodeResponse::CallRejected {
16207                reservation_id, activation_digest, record_id, session, call_id,
16208            })
16209        }
16210        NodeRequest::BeginDeliveryStage { manifest } => {
16211            shared.require_controller(connection_id, role)?;
16212            shared.begin_delivery_stage(manifest)
16213        }
16214        NodeRequest::PutDeliveryBlobChunk {
16215            stage_id,
16216            blob_digest,
16217            offset,
16218            chunk_hex,
16219        } => {
16220            shared.require_controller(connection_id, role)?;
16221            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16222        }
16223        NodeRequest::CommitDeliveryStage { stage_id } => {
16224            shared.require_controller(connection_id, role)?;
16225            let receipt = shared.commit_delivery_stage(stage_id)?;
16226            Ok(NodeResponse::DeliveryCommitted { receipt })
16227        }
16228        NodeRequest::AbortDeliveryStage { stage_id } => {
16229            shared.require_controller(connection_id, role)?;
16230            shared.abort_delivery_stage(&stage_id)?;
16231            Ok(NodeResponse::DeliveryStageAborted { stage_id })
16232        }
16233        NodeRequest::BrowseHostDirectories { directory, after } => {
16234            let listing = shared.browse_host_directories(directory, after).await?;
16235            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16236        }
16237        NodeRequest::InspectWorkspace { workspace_id } => {
16238            let inspection = shared.inspect_workspace(workspace_id).await?;
16239            Ok(NodeResponse::WorkspaceInspected { inspection })
16240        }
16241        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16242            let file = shared.read_workspace_file(workspace_id, path).await?;
16243            Ok(NodeResponse::WorkspaceFileRead { file })
16244        }
16245        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16246            shared.require_controller(connection_id, role)?;
16247            let file = shared
16248                .write_workspace_file(workspace_id, path, expected_revision, text)
16249                .await?;
16250            Ok(NodeResponse::WorkspaceFileWritten { file })
16251        }
16252        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16253            shared.require_controller(connection_id, role)?;
16254            let file = shared.create_workspace_file(workspace_id, path).await?;
16255            Ok(NodeResponse::WorkspaceFileCreated { file })
16256        }
16257        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16258            shared.require_controller(connection_id, role)?;
16259            let entry = shared
16260                .create_workspace_directory(workspace_id.clone(), path)
16261                .await?;
16262            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16263        }
16264        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16265            if role != ClientRole::Operator {
16266                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16267            }
16268            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16269                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16270            }
16271            let page = shared
16272                .read_git_history(workspace_id.clone(), path, before, limit)
16273                .await?;
16274            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16275        }
16276        NodeRequest::ReadGitDiff { workspace_id, request } => {
16277            if role != ClientRole::Operator {
16278                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16279            }
16280            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16281            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16282        }
16283        NodeRequest::CatalogNativeSessions { route, limit } => {
16284            if role != ClientRole::Operator {
16285                return Err(failure(
16286                    NodeFailureCode::ObserverReadOnly,
16287                    "native session catalog requires an operator connection",
16288                ));
16289            }
16290            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16291                return Err(failure(
16292                    NodeFailureCode::InvalidRequest,
16293                    "native session catalog limit is invalid",
16294                ));
16295            }
16296            let (entries, summary) = shared
16297                .catalog_native_sessions(route.clone(), limit)
16298                .await?;
16299            Ok(NodeResponse::NativeSessionsCataloged {
16300                route,
16301                entries,
16302                summary: Some(summary),
16303            })
16304        }
16305        NodeRequest::PageNativeSessions {
16306            route,
16307            window,
16308            catalog_revision,
16309            recent_cutoff_unix_ms,
16310            after_selection_id,
16311            limit,
16312        } => {
16313            if role != ClientRole::Operator {
16314                return Err(failure(
16315                    NodeFailureCode::ObserverReadOnly,
16316                    "native session catalog paging requires an operator connection",
16317                ));
16318            }
16319            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16320                || after_selection_id
16321                    .as_deref()
16322                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16323            {
16324                return Err(failure(
16325                    NodeFailureCode::InvalidRequest,
16326                    "native session catalog page request is invalid",
16327                ));
16328            }
16329            let page = shared
16330                .page_native_sessions(
16331                    route.clone(),
16332                    window,
16333                    catalog_revision,
16334                    recent_cutoff_unix_ms,
16335                    after_selection_id,
16336                    limit,
16337                )
16338                .await?;
16339            Ok(NodeResponse::NativeSessionsPaged {
16340                route,
16341                page,
16342            })
16343        }
16344        NodeRequest::PreviewNativeSession {
16345            selection,
16346            message_limit,
16347        } => {
16348            if role != ClientRole::Operator {
16349                return Err(failure(
16350                    NodeFailureCode::ObserverReadOnly,
16351                    "native session preview requires an operator connection",
16352                ));
16353            }
16354            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16355                .contains(&message_limit)
16356                || selection.validate().is_err()
16357            {
16358                return Err(failure(
16359                    NodeFailureCode::InvalidRequest,
16360                    "native session preview request is invalid",
16361                ));
16362            }
16363            let preview = shared
16364                .preview_native_session(selection.clone(), message_limit)
16365                .await?;
16366            Ok(NodeResponse::NativeSessionPreviewed {
16367                selection,
16368                preview,
16369            })
16370        }
16371        NodeRequest::IndexNativeSession {
16372            selection,
16373            display_name,
16374        } => {
16375            shared.require_controller(connection_id, role)?;
16376            let record = shared
16377                .index_native_session(selection.clone(), display_name)
16378                .await?;
16379            Ok(NodeResponse::NativeSessionIndexed { selection, record })
16380        }
16381        NodeRequest::PreviewSessionRecord {
16382            record_id,
16383            message_limit,
16384        } => {
16385            if role != ClientRole::Operator {
16386                return Err(failure(
16387                    NodeFailureCode::ObserverReadOnly,
16388                    "native session preview requires an operator connection",
16389                ));
16390            }
16391            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16392                .contains(&message_limit)
16393            {
16394                return Err(failure(
16395                    NodeFailureCode::InvalidRequest,
16396                    "native session preview request is invalid",
16397                ));
16398            }
16399            let record = shared.record(&record_id)?;
16400            let working_directory = shared.workspace_root(&record.workspace_id)?;
16401            if !platform::roots_equal(
16402                &working_directory,
16403                windows_path_text(&record.canonical_root),
16404            ) {
16405                return Err(failure(
16406                    NodeFailureCode::SessionRecordConflict,
16407                    "managed session workspace root changed; refusing to preview another directory",
16408                ));
16409            }
16410            let identity = record.provider_session.clone().ok_or_else(|| {
16411                failure(
16412                    NodeFailureCode::BackendOperationFailed,
16413                    "native session preview is unavailable for this session record",
16414                )
16415            })?;
16416            if identity.key != ProviderSessionKey::SessionId {
16417                return Err(failure(
16418                    NodeFailureCode::BackendOperationFailed,
16419                    "native session preview is unavailable for this session identity",
16420                ));
16421            }
16422            let preview = shared
16423                .preview_native_session_by_session_id(
16424                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16425                )
16426                .await?;
16427            if preview.session_id != identity.id {
16428                return Err(failure(
16429                    NodeFailureCode::SessionRecordConflict,
16430                    "native session preview identity changed while loading",
16431                ));
16432            }
16433            shared.revalidate_session_record_preview(&record, &identity)?;
16434            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16435            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16436                record_id: record_id.clone(),
16437                summary: SessionHistorySummaryV1::from(&preview),
16438            });
16439            Ok(NodeResponse::SessionRecordPreviewed {
16440                record_id,
16441                preview,
16442            })
16443        }
16444        NodeRequest::AcquireController { lease_ms } => {
16445            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16446            Ok(NodeResponse::Controller { controller: Some(controller) })
16447        }
16448        NodeRequest::ReleaseController => {
16449            shared.release_controller(connection_id);
16450            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16451        }
16452        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16453            shared.require_controller(connection_id, role)?;
16454            let root = require_windows_path(root)?;
16455            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16456            let workspace = shared.register_workspace(workspace_id, root).await?;
16457            Ok(NodeResponse::WorkspaceRegistered { workspace })
16458        }
16459        NodeRequest::CreateStandaloneWorkspace {
16460            workspace_id,
16461            root,
16462            initial_branch,
16463        } => {
16464            shared.require_controller(connection_id, role)?;
16465            let root = require_windows_path(root)?;
16466            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16467            let workspace = shared
16468                .create_standalone_workspace(workspace_id, root, initial_branch)
16469                .await?;
16470            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16471        }
16472        NodeRequest::UnregisterWorkspace { workspace_id } => {
16473            shared.require_controller(connection_id, role)?;
16474            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16475            shared.unregister_workspace(&workspace_id)?;
16476            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16477        }
16478        NodeRequest::CreateWorktree {
16479            source_workspace_id,
16480            workspace_id,
16481            target_root,
16482            branch,
16483            base,
16484        } => {
16485            shared.require_controller(connection_id, role)?;
16486            let target_root = require_windows_path(target_root)?;
16487            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16488            let (worktree, workspace) = shared
16489                .create_worktree(
16490                    source_workspace_id,
16491                    workspace_id,
16492                    target_root,
16493                    branch,
16494                    base,
16495                )
16496                .await?;
16497            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16498        }
16499        NodeRequest::RemoveWorktree {
16500            source_workspace_id,
16501            target_root,
16502        } => {
16503            shared.require_controller(connection_id, role)?;
16504            let native_target_root = require_windows_path(target_root.clone())?;
16505            shared.reject_managed_reservation(None, Some(&native_target_root))?;
16506            let workspace_id = shared
16507                .remove_worktree(source_workspace_id, native_target_root)
16508                .await?;
16509            Ok(NodeResponse::WorktreeRemoved {
16510                target_root,
16511                workspace_id,
16512            })
16513        }
16514        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16515            shared.require_controller(connection_id, role)?;
16516            if !terminal_size.is_valid() {
16517                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16518            }
16519            if let Some(prompt) = initial_prompt.as_deref() {
16520                validate_node_text("spawn initial prompt", prompt)?;
16521            }
16522            match shared
16523                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16524                .await
16525            {
16526                Ok(session) => {
16527                    shared.mark_spawn_provisional(session.clone());
16528                    Ok(NodeResponse::SpawnAccepted { session })
16529                }
16530                Err(err) => {
16531                    tracing::warn!(
16532                        %provider,
16533                        %workspace_id,
16534                        ?mode,
16535                        code = ?err.code,
16536                        message = %err.message,
16537                        "session spawn refused"
16538                    );
16539                    Err(err)
16540                }
16541            }
16542        }
16543        NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16544            shared.require_controller(connection_id, role)?;
16545            if !terminal_size.is_valid() {
16546                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16547            }
16548            if let Some(prompt) = initial_prompt.as_deref() {
16549                validate_node_text("spawn initial prompt", prompt)?;
16550            }
16551            let plan = backend_paths::plan_launch_options(
16552                provider.as_str(),
16553                mode,
16554                options.model.is_some(),
16555                options.effort.is_some(),
16556            )
16557            .map_err(|r| {
16558                tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16559                failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16560            })?;
16561            let selection = spawn_selection(&options)?;
16562            let pty_selection = match plan {
16563                backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16564                _ => None,
16565            };
16566            let spawned = SPAWN_SESSION_OPTIONS
16567                .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16568                .await;
16569            match spawned {
16570                Ok(session) => {
16571                    if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16572                        shared
16573                            .pending_acp_options
16574                            .lock()
16575                            .unwrap_or_else(|p| p.into_inner())
16576                            .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16577                    }
16578                    shared.mark_spawn_provisional(session.clone());
16579                    tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
16580                    Ok(NodeResponse::SpawnAccepted { session })
16581                }
16582                Err(err) => {
16583                    tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
16584                    Err(err)
16585                }
16586            }
16587        }
16588        NodeRequest::SpawnSpec { spec } => {
16589            shared.require_controller(connection_id, role)?;
16590            let receipt = shared.spawn_from_spec(spec).await?;
16591            Ok(NodeResponse::SpawnSpecAccepted { receipt })
16592        }
16593        NodeRequest::SpawnManagedWorktree { request } => {
16594            shared.require_controller(connection_id, role)?;
16595            let receipt = shared.spawn_managed_worktree(request).await?;
16596            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16597        }
16598        NodeRequest::SpawnManagedWorktreeV2 { request } => {
16599            shared.require_controller(connection_id, role)?;
16600            let receipt = shared.spawn_managed_worktree_v2(request).await?;
16601            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16602        }
16603        NodeRequest::CleanupManagedWorktree { lease_id } => {
16604            shared.require_controller(connection_id, role)?;
16605            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16606            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16607        }
16608        NodeRequest::DiscoverHistory { session, limit } => {
16609            controlled_session(shared, connection_id, role, &session)?;
16610            let candidates = shared.discover_history(&session, limit).await?;
16611            Ok(NodeResponse::HistoryDiscovered {
16612                session,
16613                candidates,
16614            })
16615        }
16616        NodeRequest::LoadHistory {
16617            session,
16618            candidate_id,
16619        } => {
16620            controlled_session(shared, connection_id, role, &session)?;
16621            let loaded = shared
16622                .load_history(&session, candidate_id)
16623                .await?;
16624            Ok(NodeResponse::HistoryLoaded {
16625                session,
16626                session_id: loaded.session_id,
16627                message_count: loaded.message_count,
16628                completed_turn_count: loaded.completed_turn_count,
16629            })
16630        }
16631        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16632            shared.require_controller(connection_id, role)?;
16633            let context = shared
16634                .export_context_pack_for_session_record(&record_id, &session, false)
16635                .await?;
16636            Ok(NodeResponse::ContextPackForSessionRecordExported {
16637                record_id,
16638                session,
16639                context,
16640            })
16641        }
16642        NodeRequest::ExportContextPack { session } => {
16643            controlled_session(shared, connection_id, role, &session)?;
16644            let context = shared.export_context_pack(&session).await?;
16645            Ok(NodeResponse::ContextPackExported { context })
16646        }
16647        NodeRequest::ForgetContextPack { context_id } => {
16648            shared.require_controller(connection_id, role)?;
16649            shared.forget_context_pack(&context_id)?;
16650            Ok(NodeResponse::ContextPackForgotten { context_id })
16651        }
16652        NodeRequest::ResolveDurableContextPack { context_id } => {
16653            // Read-only, idempotent, no controller lease required: this lets
16654            // a caller preflight "is this durably exported pack still here"
16655            // without holding the Node's mutation controller.
16656            let context = shared
16657                .context_catalog
16658                .read()
16659                .unwrap_or_else(|poisoned| poisoned.into_inner())
16660                .get(&context_id)
16661                .map(|pack| pack.receipt().clone())
16662                .ok_or_else(|| {
16663                    failure(
16664                        NodeFailureCode::UnknownContextPack,
16665                        "context pack is not present",
16666                    )
16667                })?;
16668            Ok(NodeResponse::DurableContextPackResolved { context })
16669        }
16670        NodeRequest::ReadContextPack { digest } => {
16671            // Read-only fetch of the pack's own bytes, keyed by content digest
16672            // rather than the caller's `SpawnContextId`: same no-controller-lease
16673            // rationale as `ResolveDurableContextPack` above, one step further
16674            // down the same catalog.
16675            let (id, byte_len, bytes) = shared
16676                .context_catalog
16677                .read()
16678                .unwrap_or_else(|poisoned| poisoned.into_inner())
16679                .get_by_digest(&digest)
16680                .map(|pack| {
16681                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16682                })
16683                .ok_or_else(|| {
16684                    failure(
16685                        NodeFailureCode::UnknownContextPack,
16686                        &format!("context pack {digest} not on this node"),
16687                    )
16688                })?;
16689            tracing::debug!(
16690                digest = digest.as_str(),
16691                byte_len,
16692                "read context pack bytes",
16693            );
16694            Ok(NodeResponse::ContextPackBytesRead {
16695                pack: ContextPackBytesRead {
16696                    digest,
16697                    id,
16698                    byte_len,
16699                    bytes,
16700                },
16701            })
16702        }
16703        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16704            let provider = controlled_session(shared, connection_id, role, &session)?;
16705            if let Some(prompt) = initial_prompt.as_deref() {
16706                validate_node_text("resume initial prompt", prompt)?;
16707            }
16708            let runtime_requirement = if initial_prompt.is_some() {
16709                ProviderRuntimeRequirement::ResumeWithPrompt
16710            } else {
16711                ProviderRuntimeRequirement::Resume
16712            };
16713            shared.require_session_runtime_policy(&session, runtime_requirement)?;
16714            let runtime_policy = shared
16715                .admit_provider_runtime(&provider, runtime_requirement)
16716                .await?;
16717            let working_directory = shared.bound_workspace_root(&session)?;
16718            let request = ResumeLaunchRequest {
16719                working_directory,
16720                terminal_size,
16721                initial_prompt,
16722            };
16723            request
16724                .validate()
16725                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16726            let command = shared.prepare_command(ControlCommand::Resume {
16727                instance_id: session.session.instance_id,
16728                target: ResumeTarget::CurrentProvider,
16729                runtime_policy,
16730                request,
16731            });
16732            shared.arm_resume(&session, command.id, runtime_policy)?;
16733            let dispatch = shared.dispatch_envelope(command);
16734            if let Err(error) = dispatch {
16735                shared.clear_armed_resume(&session);
16736                return Err(error);
16737            }
16738            Ok(NodeResponse::Accepted)
16739        }
16740        NodeRequest::RenameSessionRecord {
16741            record_id,
16742            display_name,
16743        } => {
16744            shared.require_controller(connection_id, role)?;
16745            let record = shared.rename_session_record(&record_id, display_name)?;
16746            Ok(NodeResponse::SessionRecordUpdated { record })
16747        }
16748        NodeRequest::SetSessionTask {
16749            record_id,
16750            expected_revision,
16751            target,
16752        } => {
16753            shared.require_controller(connection_id, role)?;
16754            let record = shared.set_session_task(&record_id, expected_revision, target)?;
16755            Ok(NodeResponse::SessionRecordUpdated { record })
16756        }
16757        NodeRequest::IndexProviderSession {
16758            workspace_id,
16759            provider,
16760            identity,
16761            display_name,
16762        } => {
16763            shared.require_controller(connection_id, role)?;
16764            let record = shared.index_provider_session(
16765                workspace_id,
16766                provider,
16767                identity,
16768                display_name,
16769            )?;
16770            Ok(NodeResponse::ProviderSessionIndexed { record })
16771        }
16772        NodeRequest::ResumeSessionRecord {
16773            record_id,
16774            terminal_size,
16775            initial_prompt,
16776        } => {
16777            shared.require_controller(connection_id, role)?;
16778            if let Some(prompt) = initial_prompt.as_deref() {
16779                validate_node_text("managed resume initial prompt", prompt)?;
16780            }
16781            let (record, session) = shared
16782                .resume_session_record(
16783                    &record_id,
16784                    terminal_size,
16785                    initial_prompt,
16786                )
16787                .await?;
16788            Ok(NodeResponse::SessionRecordResumed { record, session })
16789        }
16790        NodeRequest::ForgetSessionRecord { record_id } => {
16791            shared.require_controller(connection_id, role)?;
16792            shared.forget_session_record(&record_id).await?;
16793            Ok(NodeResponse::SessionRecordForgotten { record_id })
16794        }
16795        NodeRequest::Prompt { session, text } => {
16796            let agent_id = controlled_session(shared, connection_id, role, &session)?;
16797            validate_node_text("prompt", &text)?;
16798            shared.require_session_runtime_policy(
16799                &session,
16800                ProviderRuntimeRequirement::SemanticPrompt,
16801            )?;
16802            let framing = prompt_framing(&agent_id);
16803            shared
16804                .dispatch_input_bounded(
16805                    &session,
16806                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
16807                )
16808                .await?;
16809            Ok(NodeResponse::Accepted)
16810        }
16811        NodeRequest::Paste { session, text } => {
16812            controlled_session(shared, connection_id, role, &session)?;
16813            validate_node_text("paste", &text)?;
16814            shared.require_session_runtime_policy(
16815                &session,
16816                ProviderRuntimeRequirement::SemanticPrompt,
16817            )?;
16818            shared
16819                .dispatch_input_bounded(
16820                    &session,
16821                    InputAction::InsertDraft(PromptPayload {
16822                        text,
16823                        framing: PromptFraming::BracketedPaste,
16824                    }),
16825                )
16826                .await?;
16827            Ok(NodeResponse::Accepted)
16828        }
16829        NodeRequest::Input { session, text } => {
16830            controlled_session(shared, connection_id, role, &session)?;
16831            validate_node_text("terminal input", &text)?;
16832            shared
16833                .dispatch_input_bounded(
16834                    &session,
16835                    InputAction::TerminalText(TerminalText { text }),
16836                )
16837                .await?;
16838            Ok(NodeResponse::Accepted)
16839        }
16840        NodeRequest::TerminalBytes { session, bytes } => {
16841            controlled_session(shared, connection_id, role, &session)?;
16842            shared.sessionize_spawn(session.session.instance_id);
16843            let action = terminal_bytes_action(bytes)?;
16844            shared
16845                .dispatch_input_bounded(&session, action)
16846                .await?;
16847            Ok(NodeResponse::Accepted)
16848        }
16849        NodeRequest::TerminalControl { session, control } => {
16850            controlled_session(shared, connection_id, role, &session)?;
16851            shared
16852                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16853                .await?;
16854            Ok(NodeResponse::Accepted)
16855        }
16856        NodeRequest::Resize { session, size } => {
16857            controlled_session(shared, connection_id, role, &session)?;
16858            shared.sessionize_spawn(session.session.instance_id);
16859            if !size.is_valid() {
16860                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16861            }
16862            shared.dispatch_resize_bounded(&session, size).await?;
16863            Ok(NodeResponse::Accepted)
16864        }
16865        NodeRequest::Interrupt { session } => {
16866            controlled_session(shared, connection_id, role, &session)?;
16867            shared
16868                .dispatch_input_bounded(
16869                    &session,
16870                    InputAction::TerminalControl(TerminalControl::Interrupt),
16871                )
16872                .await?;
16873            Ok(NodeResponse::Accepted)
16874        }
16875        NodeRequest::Stop { session, force } => {
16876            controlled_session(shared, connection_id, role, &session)?;
16877            shared.sessionize_spawn(session.session.instance_id);
16878            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16879            shared.retire_session_grok_leader(session.session.instance_id);
16880            Ok(NodeResponse::Accepted)
16881        }
16882        NodeRequest::Remove { session } => {
16883            controlled_session(shared, connection_id, role, &session)?;
16884            shared.remove_session(&session).await?;
16885            Ok(NodeResponse::Accepted)
16886        }
16887        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16888            shared.require_controller(connection_id, role)?;
16889            let snapshot = shared.require_acp_session(&session)?;
16890            let interaction = snapshot
16891                .provider
16892                .interactions
16893                .iter()
16894                .find(|interaction| {
16895                    interaction_correlation(
16896                        session.session.instance_id,
16897                        session.session.generation,
16898                        interaction.id.0,
16899                    ) == correlation_id
16900                })
16901                .ok_or_else(|| failure(
16902                    NodeFailureCode::InvalidRequest,
16903                    "unknown interaction correlation",
16904                ))?;
16905            if interaction.status != ProviderInteractionStatus::Pending {
16906                return Err(failure(
16907                    NodeFailureCode::BackendOperationFailed,
16908                    "interaction has already been resolved",
16909                ));
16910            }
16911            response
16912                .validate_for(interaction.interaction_kind)
16913                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16914            let interaction_id = interaction.id;
16915            let response_kind = response.kind();
16916            let response_outcome = response.outcome();
16917            shared.dispatch(ControlCommand::ResolveInteraction {
16918                instance_id: session.session.instance_id,
16919                generation: session.session.generation,
16920                interaction_id,
16921                response,
16922            })?;
16923            // The one line that lets an operator compare what THIS node
16924            // recorded against what the provider itself reports doing --
16925            // measured live on a kimi run where our side resolved `true`
16926            // while the agent reported "rejected" and neither layer had a
16927            // line to line up against the other.
16928            tracing::info!(
16929                node_id = %shared.node_id,
16930                session = ?session.session,
16931                correlation_id = %correlation_id,
16932                interaction_id = interaction_id.0,
16933                response_kind = ?response_kind,
16934                outcome = ?response_outcome,
16935                "interaction resolved",
16936            );
16937            Ok(NodeResponse::Accepted)
16938        }
16939        NodeRequest::SetSessionMode { session, mode_id } => {
16940            shared.require_controller(connection_id, role)?;
16941            shared.require_acp_session(&session)?;
16942            shared.dispatch(ControlCommand::SetSessionMode {
16943                instance_id: session.session.instance_id,
16944                mode_id,
16945            })?;
16946            Ok(NodeResponse::Accepted)
16947        }
16948        NodeRequest::SetSessionConfigOption {
16949            session,
16950            option_id,
16951            value_json,
16952        } => {
16953            shared.require_controller(connection_id, role)?;
16954            shared.require_acp_session(&session)?;
16955            shared.dispatch(ControlCommand::SetSessionConfigOption {
16956                instance_id: session.session.instance_id,
16957                option_id,
16958                value_json,
16959            })?;
16960            Ok(NodeResponse::Accepted)
16961        }
16962        NodeRequest::SetSessionModel { session, model_id } => {
16963            shared.require_controller(connection_id, role)?;
16964            shared.require_acp_session(&session)?;
16965            shared.dispatch(ControlCommand::SetSessionModel {
16966                instance_id: session.session.instance_id,
16967                model_id,
16968            })?;
16969            Ok(NodeResponse::Accepted)
16970        }
16971        NodeRequest::Shutdown => {
16972            shared.require_controller(connection_id, role)?;
16973            shared.begin_shutdown_locked().await?;
16974            Ok(NodeResponse::ShuttingDown)
16975        }
16976    }
16977}
16978
16979fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16980    shared.require_controller(connection_id, role)?;
16981    shared.validate_address(session)
16982}
16983
16984/// Selects the sole history candidate matching `provider_session_id` when
16985/// the managed record carries a verified identity, or the sole candidate in
16986/// the discovered set at all when it does not (see
16987/// `export_context_pack_for_session_record_inner`'s identity match). Either
16988/// way, more than one match refuses instead of guessing which one is exact.
16989fn select_history_candidate(
16990    candidates: &[HistoryCandidateSummary],
16991    provider_session_id: Option<&str>,
16992) -> Result<String, NodeFailure> {
16993    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16994        Some(id) => candidate.session_id_hint == id,
16995        None => true,
16996    });
16997    let candidate = matches.next().ok_or_else(|| {
16998        failure(
16999            NodeFailureCode::SessionRecordConflict,
17000            if provider_session_id.is_some() {
17001                "no history candidate matches the managed provider session"
17002            } else {
17003                "no history candidate exists for the managed session"
17004            },
17005        )
17006    })?;
17007    if matches.next().is_some() {
17008        return Err(failure(
17009            NodeFailureCode::SessionRecordConflict,
17010            if provider_session_id.is_some() {
17011                "history identity is ambiguous for the managed provider session"
17012            } else {
17013                "history is ambiguous without a verified provider session identity"
17014            },
17015        ));
17016    }
17017    Ok(candidate.id.clone())
17018}
17019
17020fn resolved_spawn_profile_summary(
17021    profile: &SpawnProfileDefaults,
17022    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17023) -> Option<SpawnProfileSummary> {
17024    let environment_profile = match profile.environment_profile_id.as_ref() {
17025        None => None,
17026        Some(profile_id) => {
17027            let binding = environment_profiles.get(profile_id)?;
17028            if binding.provider != profile.provider
17029                || binding.native_profile_id(profile.mode).is_none()
17030            {
17031                return None;
17032            }
17033            Some(ResolvedEnvironmentProfileReceipt {
17034                profile_id: binding.id.clone(),
17035                profile_revision: binding.revision.clone(),
17036                network_allowlist: None,
17037                browser_profile_id: None,
17038            })
17039        }
17040    };
17041    Some(SpawnProfileSummary {
17042        id: profile.profile_id.clone(),
17043        revision: profile.revision.clone(),
17044        environment_profile,
17045    })
17046}
17047
17048/// True if `current` is an acceptable version of `expected` at export
17049/// revalidation/commit time.
17050///
17051/// When `allow_clean_detachment` is `false` (the on-demand,
17052/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
17053/// be byte-identical to `expected` and still actively bound to `session` —
17054/// the original, unchanged invariant.
17055///
17056/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
17057/// ALSO be the exact Live -> Dormant transition that the same clean exit this
17058/// export reacts to produces on its own: `state` Live -> Dormant,
17059/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
17060/// advancing. Every other field — including `provider_session`, `context`,
17061/// `bundle`, `task_binding` — must still match exactly. A record that changed
17062/// in any other way (rebind, a different active session, an identity
17063/// conflict, capacity eviction) is rejected either way; this widens nothing
17064/// about which sessions are eligible, only how the record's own expected
17065/// detachment is tolerated mid-export.
17066fn session_record_export_target_matches(
17067    expected: &ManagedSessionRecord,
17068    current: &ManagedSessionRecord,
17069    session: &SessionAddress,
17070    allow_clean_detachment: bool,
17071) -> bool {
17072    if current == expected && current.active_session.as_ref() == Some(session) {
17073        return current.state == ManagedSessionState::Live;
17074    }
17075    allow_clean_detachment
17076        && current.state == ManagedSessionState::Dormant
17077        && current.active_session.is_none()
17078        && current.record_id == expected.record_id
17079        && current.display_name == expected.display_name
17080        && current.provider == expected.provider
17081        && current.mode == expected.mode
17082        && current.workspace_id == expected.workspace_id
17083        && current.canonical_root == expected.canonical_root
17084        && current.provider_session == expected.provider_session
17085        && current.environment_profile == expected.environment_profile
17086        && current.bundle == expected.bundle
17087        && current.context_id == expected.context_id
17088        && current.context == expected.context
17089        && current.exported_context == expected.exported_context
17090        && current.task_binding == expected.task_binding
17091        && current.created_at_unix_ms == expected.created_at_unix_ms
17092        && current.last_error == expected.last_error
17093}
17094
17095fn session_record_context_export_binding_is_exact(
17096    expected: &ManagedSessionRecord,
17097    current: &ManagedSessionRecord,
17098    identity: Option<&ProviderSessionIdentity>,
17099    session: &SessionAddress,
17100    binding: &SessionBinding,
17101    provider: &AgentId,
17102    current_root: &str,
17103    allow_clean_detachment: bool,
17104) -> bool {
17105    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17106        && current.provider_session.as_ref() == identity
17107        && current.workspace_id == session.workspace_id
17108        && provider == &current.provider
17109        && binding.workspace_id == session.workspace_id
17110        && binding.generation == session.session.generation
17111        && binding.record_id.as_ref() == Some(&current.record_id)
17112        && platform::roots_equal(
17113            current_root,
17114            windows_path_text(&current.canonical_root),
17115        )
17116}
17117
17118fn session_record_context_export_source_is_usable(
17119    record: &ManagedSessionRecord,
17120    status: &SessionStatus,
17121    allow_clean_detachment: bool,
17122) -> bool {
17123    (record.state == ManagedSessionState::Live
17124        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17125        && context_pack_source_status_is_usable(status)
17126}
17127
17128fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17129    PromptFraming::BracketedPaste
17130}
17131
17132fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17133    if text.len() > MAX_NODE_TEXT_BYTES {
17134        return Err(failure(
17135            NodeFailureCode::InvalidRequest,
17136            &format!(
17137                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17138                text.len(),
17139            ),
17140        ));
17141    }
17142    Ok(())
17143}
17144
17145fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17146    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17147        return Err(failure(
17148            NodeFailureCode::InvalidRequest,
17149            &format!(
17150                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17151                bytes.len(),
17152            ),
17153        ));
17154    }
17155    Ok(())
17156}
17157
17158fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17159    validate_terminal_bytes(&bytes)?;
17160    Ok(InputAction::TerminalBytes(bytes))
17161}
17162
17163fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17164    if root.is_empty()
17165        || root.len() > MAX_WORKSPACE_ROOT_BYTES
17166        || root.chars().any(char::is_control)
17167    {
17168        return Err(failure(
17169            NodeFailureCode::InvalidWorkspaceRoot,
17170            &format!(
17171                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17172            ),
17173        ));
17174    }
17175    Ok(())
17176}
17177
17178fn canonical_standalone_workspace_candidate(
17179    workspace_id: &WorkspaceId,
17180    root: &str,
17181) -> Result<String, NodeServerError> {
17182    let path = Path::new(root);
17183    match std::fs::symlink_metadata(path) {
17184        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17185            .map(|workspace| workspace.canonical_root),
17186        Err(error) if error.kind() == io::ErrorKind::NotFound => {
17187            if !path.is_absolute() {
17188                return Err(NodeServerError::InvalidWorkspaceRoot {
17189                    workspace_id: workspace_id.clone(),
17190                    path: root.to_owned(),
17191                    message: "path is not absolute".to_owned(),
17192                });
17193            }
17194            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17195                workspace_id: workspace_id.clone(),
17196                path: root.to_owned(),
17197                message: "path has no standalone directory name".to_owned(),
17198            })?;
17199            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17200                workspace_id: workspace_id.clone(),
17201                path: root.to_owned(),
17202                message: "path has no parent directory".to_owned(),
17203            })?;
17204            let canonical = std::fs::canonicalize(parent)
17205                .map(|parent| parent.join(name))
17206                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17207                    workspace_id: workspace_id.clone(),
17208                    path: root.to_owned(),
17209                    message: error.to_string(),
17210                })?;
17211            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17212                NodeServerError::InvalidWorkspaceRoot {
17213                    workspace_id: workspace_id.clone(),
17214                    path: path.to_string_lossy().into_owned(),
17215                    message: "canonical path is not valid Unicode".to_owned(),
17216                }
17217            })?;
17218            let canonical_root = platform::normalize_canonical_root(canonical_root);
17219            validate_workspace_root(workspace_id, &canonical_root)?;
17220            Ok(canonical_root)
17221        }
17222        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17223            workspace_id: workspace_id.clone(),
17224            path: root.to_owned(),
17225            message: error.to_string(),
17226        }),
17227    }
17228}
17229
17230fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17231    let Some(revision) = revision else {
17232        return Ok(());
17233    };
17234    if revision.is_empty()
17235        || revision.len() > MAX_REPOSITORY_PATH_BYTES
17236        || revision.chars().any(char::is_control)
17237    {
17238        return Err(failure(
17239            NodeFailureCode::InvalidRequest,
17240            &format!(
17241                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17242            ),
17243        ));
17244    }
17245    Ok(())
17246}
17247
17248fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17249    let code = match error.kind {
17250        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17251        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17252        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17253        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17254        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17255        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17256        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17257    };
17258    failure(code, &error.message)
17259}
17260
17261fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17262    let code = match error.kind {
17263        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17264        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17265        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17266            NodeFailureCode::BackendOperationFailed
17267        }
17268        StandaloneWorkspaceErrorKind::RecoveryRequired => {
17269            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17270        }
17271    };
17272    failure(code, error.message)
17273}
17274
17275fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17276    let (code, message) = match error.kind {
17277        GitWorktreeErrorKind::Invalid => (
17278            NodeFailureCode::InvalidRequest,
17279            "managed Git input is invalid",
17280        ),
17281        GitWorktreeErrorKind::NotRepository => (
17282            NodeFailureCode::NotGitRepository,
17283            "managed source is not a Git repository",
17284        ),
17285        GitWorktreeErrorKind::Conflict => (
17286            NodeFailureCode::WorktreeConflict,
17287            "managed Git identity conflicts with existing repository state",
17288        ),
17289        GitWorktreeErrorKind::Protected => (
17290            NodeFailureCode::ManagedWorktreeOwnershipConflict,
17291            "managed worktree ownership validation failed",
17292        ),
17293        GitWorktreeErrorKind::Dirty => (
17294            NodeFailureCode::WorktreeDirty,
17295            "managed worktree contains uncommitted changes",
17296        ),
17297        GitWorktreeErrorKind::Locked => (
17298            NodeFailureCode::WorktreeLocked,
17299            "managed worktree is locked",
17300        ),
17301        GitWorktreeErrorKind::Failed => (
17302            NodeFailureCode::BackendOperationFailed,
17303            "managed Git operation failed",
17304        ),
17305    };
17306    NodeFailure { code, message: message.to_owned() }
17307}
17308
17309fn managed_spawn_receipt(
17310    resolved: &ResolvedSpawnSpec,
17311    incarnation_id: NodeIncarnationId,
17312    session: SessionAddress,
17313    lease: ManagedWorktreeLeaseSnapshot,
17314    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17315    bundle: Option<ResolvedBundleReceipt>,
17316    context: Option<ResolvedContextPackReceipt>,
17317) -> ManagedWorktreeSpawnReceipt {
17318    let mut managed = resolved.clone();
17319    managed.target.worktree_id = Some(lease.workspace_id.clone());
17320    ManagedWorktreeSpawnReceipt {
17321        spawn: managed.receipt_with_materialization(
17322            incarnation_id,
17323            session,
17324            environment_profile,
17325            bundle,
17326            context,
17327        ),
17328        lease,
17329    }
17330}
17331
17332fn validate_selected_worktree(
17333    source_root: &str,
17334    selected_root: &str,
17335    worktrees: &[NativeGitWorktreeSnapshot],
17336) -> Result<(), NodeFailure> {
17337    if !worktrees
17338        .iter()
17339        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17340    {
17341        return Err(failure(
17342            NodeFailureCode::WorktreeProtected,
17343            "source workspace is not an authoritative non-bare Git worktree root",
17344        ));
17345    }
17346    if !worktrees.iter().any(|worktree| {
17347        worktree_paths_equal(&worktree.path, selected_root)
17348            && !worktree.is_main
17349            && !worktree.is_bare
17350    }) {
17351        return Err(failure(
17352            NodeFailureCode::WorktreeProtected,
17353            "selected workspace is not an eligible Git worktree in the source repository",
17354        ));
17355    }
17356    Ok(())
17357}
17358
17359fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17360    let code = match error.kind() {
17361        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17362        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17363            NodeFailureCode::RepositoryPathUnsafe
17364        }
17365        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17366        WorkspaceFileReadErrorKind::NotRegularFile => {
17367            NodeFailureCode::RepositoryFileNotRegular
17368        }
17369        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17370            NodeFailureCode::RepositoryFileReadFailed
17371        }
17372        WorkspaceFileReadErrorKind::RevisionConflict => {
17373            NodeFailureCode::RepositoryFileRevisionConflict
17374        }
17375        WorkspaceFileReadErrorKind::AlreadyExists
17376        | WorkspaceFileReadErrorKind::ParentNotFound
17377        | WorkspaceFileReadErrorKind::ParentNotDirectory
17378        | WorkspaceFileReadErrorKind::Canceled => {
17379            NodeFailureCode::RepositoryFileReadFailed
17380        }
17381    };
17382    failure(code, "repository file read failed")
17383}
17384
17385fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17386    let code = match error.kind() {
17387        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17388        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17389            NodeFailureCode::RepositoryPathUnsafe
17390        }
17391        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17392        WorkspaceFileReadErrorKind::NotRegularFile => {
17393            NodeFailureCode::RepositoryFileNotRegular
17394        }
17395        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17396            NodeFailureCode::RepositoryFileWriteFailed
17397        }
17398        WorkspaceFileReadErrorKind::RevisionConflict => {
17399            NodeFailureCode::RepositoryFileRevisionConflict
17400        }
17401        WorkspaceFileReadErrorKind::AlreadyExists
17402        | WorkspaceFileReadErrorKind::ParentNotFound
17403        | WorkspaceFileReadErrorKind::ParentNotDirectory
17404        | WorkspaceFileReadErrorKind::Canceled => {
17405            NodeFailureCode::RepositoryFileWriteFailed
17406        }
17407    };
17408    failure(code, "repository file write failed")
17409}
17410
17411async fn settle_workspace_entry_create<T>(
17412    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17413    commit_state: Arc<AtomicU8>,
17414    deadline: Duration,
17415    timeout_message: &'static str,
17416    task_failure_message: &'static str,
17417) -> Result<T, NodeFailure> {
17418    let joined = match timeout(deadline, &mut task).await {
17419        Ok(joined) => joined,
17420        Err(_) => {
17421            if commit_state
17422                .compare_exchange(
17423                    WORKSPACE_ENTRY_CREATE_PENDING,
17424                    WORKSPACE_ENTRY_CREATE_CANCELED,
17425                    Ordering::AcqRel,
17426                    Ordering::Acquire,
17427                )
17428                .is_ok()
17429            {
17430                return Err(failure(
17431                    NodeFailureCode::RepositoryEntryCreateTimedOut,
17432                    timeout_message,
17433                ));
17434            }
17435            debug_assert_eq!(
17436                commit_state.load(Ordering::Acquire),
17437                WORKSPACE_ENTRY_CREATE_COMMITTING,
17438            );
17439            task.await
17440        }
17441    };
17442    joined
17443        .map_err(|_| {
17444            failure(
17445                NodeFailureCode::RepositoryEntryCreateFailed,
17446                task_failure_message,
17447            )
17448        })?
17449        .map_err(workspace_entry_create_failure)
17450}
17451
17452fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17453    let code = match error.kind() {
17454        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17455        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17456            NodeFailureCode::RepositoryPathUnsafe
17457        }
17458        WorkspaceFileReadErrorKind::AlreadyExists => {
17459            NodeFailureCode::RepositoryEntryAlreadyExists
17460        }
17461        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17462            NodeFailureCode::RepositoryParentNotFound
17463        }
17464        WorkspaceFileReadErrorKind::ParentNotDirectory
17465        | WorkspaceFileReadErrorKind::NotRegularFile => {
17466            NodeFailureCode::RepositoryParentNotDirectory
17467        }
17468        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17469            NodeFailureCode::RepositoryEntryCreateFailed
17470        }
17471        WorkspaceFileReadErrorKind::RevisionConflict => {
17472            NodeFailureCode::RepositoryEntryCreateFailed
17473        }
17474        WorkspaceFileReadErrorKind::Canceled => {
17475            NodeFailureCode::RepositoryEntryCreateTimedOut
17476        }
17477    };
17478    failure(code, "repository entry create failed")
17479}
17480
17481fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17482    let value = digest(&SHA256, bytes)
17483        .as_ref()
17484        .iter()
17485        .map(|byte| format!("{byte:02x}"))
17486        .collect();
17487    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17488}
17489
17490fn managed_spawn_request_digest_v2(
17491    request: &ManagedWorktreeSpawnRequestV2,
17492) -> Result<String, NodeFailure> {
17493    let encoded = serde_json::to_vec(request).map_err(|_| {
17494        failure(
17495            NodeFailureCode::InvalidRequest,
17496            "managed worktree V2 request could not be canonicalized",
17497        )
17498    })?;
17499    let mut value = String::with_capacity(71);
17500    value.push_str("sha256:");
17501    for byte in digest(&SHA256, &encoded).as_ref() {
17502        use std::fmt::Write as _;
17503        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17504    }
17505    Ok(value)
17506}
17507
17508fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17509    let code = match error {
17510        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17511        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17512        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17513        NativeSessionPreviewError::UnsupportedProvider
17514        | NativeSessionPreviewError::PreviewUnavailable
17515        | NativeSessionPreviewError::SessionNotFound
17516        | NativeSessionPreviewError::AmbiguousSession => {
17517            NodeFailureCode::BackendOperationFailed
17518        }
17519    };
17520    failure(code, "native session preview failed")
17521}
17522
17523fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17524    let code = match error.kind() {
17525        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17526        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17527    };
17528    failure(code, "host directory browse failed")
17529}
17530
17531fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17532    let code = match error {
17533        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17534        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17535        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17536            NodeFailureCode::DeliveryStageConflict
17537        }
17538        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17539        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17540            NodeFailureCode::DeliveryChunkOutOfOrder
17541        }
17542        DeliveryStoreError::BlobDigestMismatch => {
17543            NodeFailureCode::DeliveryBlobDigestMismatch
17544        }
17545        DeliveryStoreError::BundleDigestMismatch => {
17546            NodeFailureCode::DeliveryBundleDigestMismatch
17547        }
17548        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17549        DeliveryStoreError::Unavailable
17550        | DeliveryStoreError::Corrupt
17551        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17552    };
17553    failure(code, "delivery operation failed")
17554}
17555
17556fn validate_workspace_root(
17557    workspace_id: &WorkspaceId,
17558    canonical_root: &str,
17559) -> Result<(), NodeServerError> {
17560    #[cfg(windows)]
17561    if canonical_root.starts_with(r"\\") {
17562        return Err(NodeServerError::InvalidWorkspaceRoot {
17563            workspace_id: workspace_id.clone(),
17564            path: canonical_root.to_owned(),
17565            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17566        });
17567    }
17568    if !platform::workspace_root_supported(canonical_root)
17569        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17570    {
17571        return Err(NodeServerError::InvalidWorkspaceRoot {
17572            workspace_id: workspace_id.clone(),
17573            path: canonical_root.to_owned(),
17574            message: "canonical path must be absolute, bounded, and free of control characters"
17575                .to_owned(),
17576        });
17577    }
17578    Ok(())
17579}
17580
17581fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17582    NodeFailure {
17583        code,
17584        message: node_failure_category(code).to_owned(),
17585    }
17586}
17587
17588fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17589    let code = match error {
17590        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17591        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17592        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17593        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17594        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17595        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17596        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17597        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17598        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17599    };
17600    failure(code, "harness MCP proxy operation failed")
17601}
17602
17603/// Numbers each harness-MCP overlay this process constructs, in
17604/// construction order, so two overlays built back to back in one node process
17605/// still get distinct trace files.
17606static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17607
17608/// Builds the optional trace environment entry of one harness-MCP overlay.
17609/// The caller's launch description names both the entry and the node-process
17610/// variable that opts in: when that variable holds a non-empty directory, the
17611/// entry names a trace file under it. Returns `None` when the launch has no
17612/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
17613/// the directory; that is the operator's job.
17614fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17615    let trace = launch.trace.as_ref()?;
17616    let trace_dir = std::env::var(&trace.dir_env).ok()?;
17617    if trace_dir.is_empty() {
17618        return None;
17619    }
17620    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17621    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17622    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17623}
17624
17625fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17626    let remaining = deadline.saturating_duration_since(Instant::now());
17627    if remaining.is_zero() {
17628        Err(failure(
17629            NodeFailureCode::SpawnDeadlineExceeded,
17630            "spawn deadline elapsed",
17631        ))
17632    } else {
17633        Ok(remaining)
17634    }
17635}
17636
17637fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17638    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17639    match deadline {
17640        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17641        None => Ok(bounded),
17642    }
17643}
17644
17645fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17646    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17647        failure(
17648            NodeFailureCode::SpawnDeadlineExceeded,
17649            "spawn dispatch exceeded the spawn deadline",
17650        )
17651    } else {
17652        error
17653    }
17654}
17655
17656fn spawn_runtime_capabilities(
17657    capabilities: &SpawnRequiredCapabilities,
17658) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17659    capabilities
17660        .iter()
17661        .map(|capability| match capability.as_str() {
17662            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17663            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17664            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17665            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17666                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17667            }
17668            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17669            _ => Err(failure(
17670                NodeFailureCode::UnsupportedSpawnCapability,
17671                "spawn required capability is unknown",
17672            )),
17673        })
17674        .collect()
17675}
17676
17677fn persistence_failure(_error: io::Error) -> NodeFailure {
17678    NodeFailure {
17679        code: NodeFailureCode::BackendOperationFailed,
17680        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17681    }
17682}
17683
17684fn node_failure_category(code: NodeFailureCode) -> &'static str {
17685    match code {
17686        NodeFailureCode::InvalidRequest => "invalid-request",
17687        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17688        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17689        NodeFailureCode::ReservationNotFound => "reservation-not-found",
17690        NodeFailureCode::ReservationConflict => "reservation-conflict",
17691        NodeFailureCode::ReservationExpired => "reservation-expired",
17692        NodeFailureCode::BindingMismatch => "binding-mismatch",
17693        NodeFailureCode::NotActivated => "not-activated",
17694        NodeFailureCode::CallNotFound => "call-not-found",
17695        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17696        NodeFailureCode::ResponseTooLarge => "response-too-large",
17697        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17698        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17699        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17700        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17701        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17702        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17703        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17704        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17705        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17706        NodeFailureCode::Unauthorized => "unauthorized",
17707        NodeFailureCode::ObserverReadOnly => "observer-read-only",
17708        NodeFailureCode::ControllerBusy => "controller-busy",
17709        NodeFailureCode::ControllerRequired => "controller-required",
17710        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17711        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17712        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17713        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17714        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17715        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17716        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17717        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17718        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17719        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17720        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17721        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17722        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17723        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17724        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17725        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17726        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17727        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17728        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17729        NodeFailureCode::GitReadFailed => "git-read-failed",
17730        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17731        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17732        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17733        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17734        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17735        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17736        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17737        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17738        NodeFailureCode::TurnInFlight => "turn-in-flight",
17739        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17740        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17741        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17742            "unsupported-network-allowlist-mapping"
17743        }
17744        NodeFailureCode::BrowserStationProbeUnavailable => {
17745            "browser-station-probe-unavailable"
17746        }
17747        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17748        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17749        NodeFailureCode::EnvironmentProfileBindingMismatch => {
17750            "environment-profile-binding-mismatch"
17751        }
17752        NodeFailureCode::UnknownBundle => "unknown-bundle",
17753        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17754        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17755        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17756        NodeFailureCode::ContextPackBusy => "context-pack-busy",
17757        NodeFailureCode::ContextPackMaterializationFailed => {
17758            "context-pack-materialization-failed"
17759        }
17760        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17761        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17762        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17763        NodeFailureCode::WorkspaceBusy => "workspace-busy",
17764        NodeFailureCode::LastWorkspace => "last-workspace",
17765        NodeFailureCode::NotGitRepository => "not-git-repository",
17766        NodeFailureCode::WorktreeConflict => "worktree-conflict",
17767        NodeFailureCode::WorktreeProtected => "worktree-protected",
17768        NodeFailureCode::WorktreeDirty => "worktree-dirty",
17769        NodeFailureCode::WorktreeLocked => "worktree-locked",
17770        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17771        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17772        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17773        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17774            "managed-worktree-profile-revision-mismatch"
17775        }
17776        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17777        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17778            "standalone-workspace-recovery-required"
17779        }
17780        NodeFailureCode::UnknownSession => "unknown-session",
17781        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17782        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17783        NodeFailureCode::SessionRecordBusy => "session-record-busy",
17784        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17785        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17786        NodeFailureCode::WorkspaceRegistrationRequired => {
17787            "workspace-registration-required"
17788        }
17789        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17790        NodeFailureCode::StaleGeneration => "stale-generation",
17791        NodeFailureCode::BackendBusy => "backend-busy",
17792        NodeFailureCode::BackendDisconnected => "backend-disconnected",
17793        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17794        NodeFailureCode::ShuttingDown => "shutting-down",
17795    }
17796}
17797
17798fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17799    NodeServerError::DurableState(io::Error::new(error.kind(), category))
17800}
17801
17802fn durable_state_load_error(error: io::Error) -> NodeServerError {
17803    let category = match session_registry::state_load_refusal(&error) {
17804        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17805            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17806        }
17807        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17808            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17809        }
17810        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17811            DURABLE_STATE_CONFLICT_ERROR
17812        }
17813        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17814    };
17815    durable_state_server_error(error, category)
17816}
17817
17818fn unix_time_ms() -> u64 {
17819    std::time::SystemTime::now()
17820        .duration_since(std::time::UNIX_EPOCH)
17821        .unwrap_or_default()
17822        .as_millis()
17823        .min(u64::MAX as u128) as u64
17824}
17825
17826fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17827    if record.provider_session.is_some() {
17828        ManagedSessionState::Dormant
17829    } else {
17830        ManagedSessionState::Unavailable
17831    }
17832}
17833
17834fn active_record_state(
17835    identity_admitted: bool,
17836    provider_session_is_known: bool,
17837) -> ManagedSessionState {
17838    if identity_admitted && !provider_session_is_known {
17839        ManagedSessionState::IdentityPending
17840    } else {
17841        ManagedSessionState::Live
17842    }
17843}
17844
17845fn managed_worktree_record_holders(
17846    records: &[ManagedSessionRecord],
17847) -> Vec<(SessionRecordId, WorkspaceId)> {
17848    records
17849        .iter()
17850        .filter(|record| record.provider_session.is_some())
17851        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17852        .collect()
17853}
17854
17855fn managed_spawn_record_ownership(
17856    binding: &SessionBinding,
17857    effective_runtime_policy: ProviderRuntimePolicy,
17858) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17859    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17860    if effective_runtime_policy.provider_session_identity {
17861        (binding.record_id.clone(), None)
17862    } else {
17863        (None, binding.record_id.clone())
17864    }
17865}
17866
17867#[derive(Debug, Error)]
17868pub enum NodeServerError {
17869    #[error("node endpoint must be a bounded absolute local transport path")]
17870    InvalidEndpoint,
17871    #[error("node access token must contain 1..=4096 bytes")]
17872    InvalidAccessToken,
17873    #[error("node HTTP observer must listen on a loopback address: {0}")]
17874    InvalidApiListen(std::net::SocketAddr),
17875    #[error("node browser bridge must listen on a loopback address: {0}")]
17876    InvalidBridgeListen(std::net::SocketAddr),
17877    #[error("node browser bridge token must contain 1..=4096 bytes")]
17878    InvalidBridgeToken,
17879    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17880    BridgeUnderlayRequiresBridgeListen,
17881    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17882    BridgeUnderlayRequiresBridgeToken,
17883    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17884    InvalidBridgeUnderlayToken,
17885    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17886    InvalidBridgeUnderlayKey,
17887    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17888    InvalidCallHome(std::net::SocketAddr),
17889    #[error("node could not reach its call-home relay: {0}")]
17890    CallHomeTransport(#[source] std::io::Error),
17891    #[error("node durable state path must be an absolute file path: {0}")]
17892    InvalidStatePath(String),
17893    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17894    InvalidHarnessMcpHelper,
17895    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17896    InvalidNetworkAllowlistCatalogPath(String),
17897    #[error("network allowlist catalog could not be read: {0}")]
17898    NetworkAllowlistCatalogIo(#[source] std::io::Error),
17899    #[error("network allowlist catalog line {line}: {message}")]
17900    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17901    #[error("network allowlist catalog: {0}")]
17902    InvalidNetworkAllowlistCatalog(String),
17903    #[error("network allowlist catalog exceeds the {max}-entry limit")]
17904    NetworkAllowlistCatalogCapacity { max: usize },
17905    #[error("the local state directory is unavailable; supply an explicit state path")]
17906    LocalStateDirectoryUnavailable,
17907    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17908    LocalRuntimeDirectoryUnavailable,
17909    #[error("node durable state failed: {0}")]
17910    DurableState(io::Error),
17911    #[error("node requires at least one configured workspace")]
17912    NoWorkspaces,
17913    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17914    InvalidWorkspaceRoot {
17915        workspace_id: WorkspaceId,
17916        path: String,
17917        message: String,
17918    },
17919    #[error("duplicate workspace ID: {0}")]
17920    DuplicateWorkspaceId(WorkspaceId),
17921    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17922    DuplicateWorkspaceRoot {
17923        first: WorkspaceId,
17924        second: WorkspaceId,
17925        root: String,
17926    },
17927    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17928    ManagedWorktreeProfileRequired(WorkspaceId),
17929    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17930    ManagedWorktreeProfileModeMismatch(WorkspaceId),
17931    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17932    DuplicateManagedWorktreeProfile {
17933        workspace_id: WorkspaceId,
17934        profile_id: WorktreeProfileId,
17935    },
17936    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17937    ManagedWorktreeProfileCapacity {
17938        workspace_id: WorkspaceId,
17939        max: usize,
17940    },
17941    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17942    InvalidManagedWorktreeProfile {
17943        workspace_id: WorkspaceId,
17944        profile_id: WorktreeProfileId,
17945        message: String,
17946    },
17947    #[error("duplicate node environment profile: {0}")]
17948    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17949    #[error("node environment profile provider is unavailable: {0}")]
17950    EnvironmentProfileProviderUnavailable(AgentId),
17951    #[error("node environment profile capacity is {max}")]
17952    EnvironmentProfileCapacity { max: usize },
17953    #[error("node environment profiles reuse native profile ID '{0}'")]
17954    DuplicateNativeEnvironmentProfile(String),
17955    #[error("node native environment profile is invalid: {0}")]
17956    NativeEnvironmentProfile(String),
17957    #[error("node session-environment materialization root must be absolute")]
17958    InvalidSessionEnvironmentRoot,
17959    #[error("node session-environment materializer is unavailable")]
17960    SessionEnvironmentMaterializerRequired,
17961    #[error("node session-environment materializer failed to initialize")]
17962    SessionEnvironmentMaterializer,
17963    #[error("node bundle catalog is invalid: {0}")]
17964    BundleCatalog(String),
17965    #[error("node delivery store failed to initialize: {0}")]
17966    DeliveryStore(String),
17967    #[error("node context pack store failed to initialize")]
17968    ContextPackStore,
17969    #[error("active agent registry failed: {0}")]
17970    Registry(String),
17971    #[error("node provider contract manifest is invalid: {0}")]
17972    ProviderContractManifest(String),
17973    #[error("named pipe I/O failed: {0}")]
17974    Io(#[from] io::Error),
17975    #[error("node HTTP observer failed: {0}")]
17976    HttpApi(io::Error),
17977    #[error("node browser bridge failed: {0}")]
17978    BridgeApi(io::Error),
17979    #[error(transparent)]
17980    Frame(#[from] FrameError),
17981    #[error("node handshake failed: {0}")]
17982    Handshake(String),
17983    #[error("node authentication frame was not received before the bounded deadline")]
17984    AuthenticationTimedOut,
17985    #[error("node authentication primitive failed: {0}")]
17986    Authentication(String),
17987    #[error("node incarnation identity generation failed: {0}")]
17988    IncarnationIdentity(String),
17989    #[error("node pre-authentication limiter is closed")]
17990    PreauthClosed,
17991    #[error("node authenticated connection limit was reached")]
17992    AuthenticatedConnectionLimit,
17993    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17994    SignalStreamClosed,
17995    #[error("node stop-all dispatch failed: {0:?}")]
17996    ShutdownDispatch(NodeFailure),
17997    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17998    ShutdownTimedOut { active_native_sessions: usize },
17999}
18000
18001impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18002    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18003        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18004        match error {
18005            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18006            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18007            E::InvalidEntry { line, message } => {
18008                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18009            }
18010            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18011            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18012        }
18013    }
18014}
18015
18016#[cfg(test)]
18017mod agent_stream_blocked_tests {
18018    use super::*;
18019    use gate4agent_types::{
18020        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18021        ProviderSource,
18022    };
18023
18024    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18025        ProviderSource {
18026            family,
18027            binding: AdapterBinding::new(
18028                AdapterId::new(adapter).unwrap(),
18029                "fixture/v1",
18030                AdapterVerification::SyntheticFixture,
18031            )
18032            .unwrap(),
18033        }
18034    }
18035
18036    fn provider_control_event_at(
18037        family: AdapterFamily,
18038        adapter: &str,
18039        source_sequence: u64,
18040        provider_event: ProviderEvent,
18041    ) -> ControlEvent {
18042        ControlEvent {
18043            sequence: 41,
18044            command_id: None,
18045            instance_id: AgentInstanceId(7),
18046            generation: SessionGeneration(3),
18047            event: ControlEventKind::ProviderEvent {
18048                sequence: 8,
18049                source: provider_source(family, adapter),
18050                source_sequence,
18051                event: provider_event,
18052            },
18053        }
18054    }
18055
18056    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18057        provider_control_event_at(family, "fixture", 9, provider_event)
18058    }
18059
18060    fn denied_host_request(
18061        by: ProviderHostDecisionAuthority,
18062        reason: Option<String>,
18063    ) -> ControlEvent {
18064        provider_control_event_at(
18065            AdapterFamily::Acp,
18066            "claude-code",
18067            2,
18068            ProviderEvent::HostRequestObserved {
18069                method: "terminal/create".to_owned(),
18070                params_json: String::new(),
18071                decision: ProviderHostRequestDecision::Denied { by },
18072                outcome: ProviderHostRequestOutcome::Executed,
18073                reason,
18074            },
18075        )
18076    }
18077
18078    fn blocked(
18079        event: &ControlEvent,
18080    ) -> (
18081        Option<String>,
18082        String,
18083        BlockAuthorityV1,
18084        Option<String>,
18085        String,
18086        Option<String>,
18087    ) {
18088        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18089        chunk.validate().expect("minted chunk must validate");
18090        let AgentStreamChunkKindV1::Blocked {
18091            correlation_id,
18092            tool_class,
18093            authority,
18094            reason_kind,
18095            reason,
18096            help,
18097        } = chunk.kind
18098        else {
18099            panic!("expected AgentStreamChunkKindV1::Blocked");
18100        };
18101        (correlation_id, tool_class, authority, reason_kind, reason, help)
18102    }
18103
18104    /// Pins the fleet and every adapter each member declares: the fleet is
18105    /// exactly what `active_registry` says, and no member declares a hook
18106    /// adapter (lifecycle hooks are retired).
18107    #[test]
18108    fn the_enabled_provider_matrix_is_exact() {
18109        let registry = active_registry().unwrap();
18110        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
18111        assert_eq!(
18112            providers
18113                .iter()
18114                .map(|contract| contract.provider.as_str())
18115                .collect::<Vec<_>>(),
18116            // `shell` is the plain PTY provider (operator terminals and smoke tests).
18117            vec!["shell", "claude", "codex", "grok", "kimi"],
18118        );
18119        assert!(adapters.iter().all(|contract| {
18120            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18121        }));
18122    }
18123
18124    /// A gate-denied host request, WITH its reason text carried all the way
18125    /// from the ACP host, mints a `Blocked` chunk naming the gate.
18126    #[test]
18127    fn gate_denied_host_request_mints_a_blocked_chunk() {
18128        let gate_text =
18129            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18130        let event = denied_host_request(
18131            ProviderHostDecisionAuthority::Gate,
18132            Some(gate_text.to_owned()),
18133        );
18134        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18135        assert_eq!(chunk.source_sequence, 2);
18136        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18137        assert_eq!(authority, BlockAuthorityV1::HostGate);
18138        assert_eq!(reason, gate_text);
18139        assert_eq!(correlation_id, None);
18140        assert_eq!(reason_kind, None);
18141        assert_eq!(help, None);
18142        assert_eq!(tool_class, "Write");
18143    }
18144
18145    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.";
18146
18147    /// The owner's classifier sample, verbatim, arriving as a
18148    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
18149    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
18150    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
18151    /// and the guidance tail as `help`.
18152    #[test]
18153    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18154        let event = provider_control_event(
18155            AdapterFamily::Acp,
18156            ProviderEvent::ToolCompleted {
18157                id: "toolu_9".to_owned(),
18158                output: CLASSIFIER_SAMPLE.to_owned(),
18159                is_error: true,
18160                duration_ms: None,
18161                agent_id: None,
18162                non_execution_kind: Some("permission-rule".to_owned()),
18163            },
18164        );
18165        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18166        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18167        assert_eq!(reason, "Blocked by classifier");
18168        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18169        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18170        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18171    }
18172
18173    /// A `session/prompt` RPC error with `data.codexErrorInfo:
18174    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
18175    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
18176    /// vendor code as `reason_kind`.
18177    #[test]
18178    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18179        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.";
18180        let event = provider_control_event(
18181            AdapterFamily::Acp,
18182            ProviderEvent::SessionEnded {
18183                result: "JSON-RPC error -32603: Internal error".to_owned(),
18184                cost_usd: None,
18185                is_error: true,
18186                stop_reason: Some(ProviderStopReason::ProviderError {
18187                    code: -32603,
18188                    message: message.to_owned(),
18189                    vendor_code: Some("usageLimitExceeded".to_owned()),
18190                }),
18191            },
18192        );
18193        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18194        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18195        assert_eq!(reason, message);
18196        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18197        assert_eq!(tool_class, "Session");
18198    }
18199
18200    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
18201    /// `authority: ProviderRefusal`.
18202    #[test]
18203    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18204        let event = provider_control_event(
18205            AdapterFamily::Acp,
18206            ProviderEvent::SessionEnded {
18207                result: "refusal".to_owned(),
18208                cost_usd: None,
18209                is_error: true,
18210                stop_reason: Some(ProviderStopReason::Refusal),
18211            },
18212        );
18213        let (_, _, authority, _, _, _) = blocked(&event);
18214        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18215    }
18216
18217    /// A failed tool completion with no typed `non_execution_kind` -- or one
18218    /// that only says the turn stopped -- is a plain execution failure, never a
18219    /// block; nor is an agent's own narrative claiming one.
18220    #[test]
18221    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18222        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18223            let event = provider_control_event(
18224                AdapterFamily::Acp,
18225                ProviderEvent::ToolCompleted {
18226                    id: "toolu_1".to_owned(),
18227                    output: "command exited with status 1".to_owned(),
18228                    is_error: true,
18229                    duration_ms: Some(12),
18230                    agent_id: None,
18231                    non_execution_kind,
18232                },
18233            );
18234            assert!(agent_stream_chunk(&event).is_none());
18235        }
18236        let narrative = provider_control_event(
18237            AdapterFamily::Acp,
18238            ProviderEvent::ToolCompleted {
18239                id: "toolu_2".to_owned(),
18240                output: "the harness blocked the write, approval needed".to_owned(),
18241                is_error: false,
18242                duration_ms: Some(3),
18243                agent_id: None,
18244                non_execution_kind: None,
18245            },
18246        );
18247        assert!(agent_stream_chunk(&narrative).is_none());
18248    }
18249
18250    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
18251    /// execution outcome: a granted request that failed while running was
18252    /// still authorized.
18253    #[test]
18254    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18255        for (decision, outcome) in [
18256            (
18257                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18258                ProviderHostRequestOutcome::Executed,
18259            ),
18260            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18261            (
18262                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18263                ProviderHostRequestOutcome::Failed {
18264                    error: "terminal/create spawn failed: os error 3".to_owned(),
18265                },
18266            ),
18267        ] {
18268            let event = provider_control_event_at(
18269                AdapterFamily::Acp,
18270                "claude-code",
18271                3,
18272                ProviderEvent::HostRequestObserved {
18273                    method: "fs/read_text_file".to_owned(),
18274                    params_json: String::new(),
18275                    decision: decision.clone(),
18276                    outcome,
18277                    reason: None,
18278                },
18279            );
18280            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18281        }
18282    }
18283
18284    /// A deferred `session/request_permission` resolved by an operator or an
18285    /// expired deadline carries no reason text -- the chunk falls back to a
18286    /// categorical sentence naming the authority rather than inventing detail.
18287    #[test]
18288    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18289        for (by, expected_authority, expected_reason) in [
18290            (
18291                ProviderHostDecisionAuthority::Operator,
18292                BlockAuthorityV1::Operator,
18293                "declined by the operator",
18294            ),
18295            (
18296                ProviderHostDecisionAuthority::DeadlinePolicy,
18297                BlockAuthorityV1::HostDeadline,
18298                "denied by host policy after the operator deadline expired",
18299            ),
18300        ] {
18301            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18302            assert_eq!(authority, expected_authority);
18303            assert_eq!(reason, expected_reason);
18304        }
18305    }
18306
18307    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
18308    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
18309    /// outright rather than truncating.
18310    #[test]
18311    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18312        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18313        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18314            ProviderHostDecisionAuthority::Policy,
18315            Some(oversized),
18316        ));
18317        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18318    }
18319}
18320
18321#[cfg(test)]
18322mod standalone_capability_platform_tests {
18323    use super::*;
18324
18325    #[test]
18326    fn standalone_workspace_capability_requires_windows_identity_guards() {
18327        let capabilities = baseline_capabilities().unwrap();
18328        let advertised = capabilities.iter().any(|capability| {
18329            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18330        });
18331        assert_eq!(advertised, cfg!(windows));
18332
18333        let request = NodeRequest::CreateStandaloneWorkspace {
18334            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18335            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18336            initial_branch: None,
18337        };
18338        assert_eq!(
18339            request_uses_unnegotiated_capability(&request, &capabilities),
18340            !cfg!(windows),
18341        );
18342    }
18343}
18344
18345
18346#[cfg(test)]
18347mod station_knobs_resolve_tests {
18348    use super::*;
18349    use gate4agent_catalog::EnvMutation;
18350    use gate4agent_runtime_native::{
18351        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18352        NativeLaunchProfileId,
18353    };
18354    use std::ffi::OsString;
18355    use std::sync::Arc;
18356
18357    struct EmptyEnvironmentResolver;
18358
18359    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18360        fn resolve_child_environment(
18361            &self,
18362        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18363            Ok(vec![EnvMutation {
18364                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18365                value: None,
18366            }])
18367        }
18368    }
18369
18370    fn agent(value: &str) -> AgentId {
18371        AgentId::new(value).unwrap()
18372    }
18373
18374    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18375        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18376        let profile_revision =
18377            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18378        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18379            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18380            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18381            provider: agent("claude"),
18382            mode: SessionMode::Pty,
18383            terminal_size: gate4agent_types::TerminalSize {
18384                rows: 24,
18385                columns: 80,
18386            },
18387            prompt: None,
18388            bundle_id: None,
18389            context_id: None,
18390            environment_profile_id: Some(profile_id.clone()),
18391        };
18392        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18393        let catalog = active_registry().unwrap();
18394        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18395        let control = runtime.native_launch_profile_control();
18396        let node_profile = NodeEnvironmentProfile::new(
18397            profile_id.clone(),
18398            profile_revision,
18399            agent("claude"),
18400            [NativeLaunchProfile::new(
18401                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18402                agent("claude"),
18403                TransportKind::Pty,
18404                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18405                Arc::new(EmptyEnvironmentResolver),
18406            )
18407            .unwrap()],
18408        )
18409        .unwrap();
18410        let (binding, native_profiles, _materialization) = node_profile.into_parts();
18411        for native_profile in native_profiles {
18412            runtime.upsert_native_launch_profile(native_profile).unwrap();
18413        }
18414        let workspace = WorkspaceConfig::new(
18415            WorkspaceId::new("primary").unwrap(),
18416            std::env::current_dir().unwrap(),
18417        )
18418        .unwrap();
18419        let shared = NodeShared::new_with_incarnation(
18420            handle,
18421            "fixture-token".to_owned(),
18422            NodeId::new("node-station-knobs-test").unwrap(),
18423            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18424            vec![workspace],
18425            vec![agent("claude")],
18426            ProviderRuntimeStatuses::default(),
18427            None,
18428            Vec::new(),
18429            Vec::new(),
18430            spawn_profiles,
18431            Some(control),
18432            None,
18433            None,
18434            None,
18435            Vec::new(),
18436            Vec::new(),
18437            Vec::new(),
18438            Vec::new(),
18439            None,
18440            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18441        );
18442        shared
18443            .environment_profiles
18444            .write()
18445            .unwrap_or_else(|poisoned| poisoned.into_inner())
18446            .insert(binding.id.clone(), binding);
18447        (shared, spawn_defaults)
18448    }
18449
18450    /// Empty-default catalog refuses unknown network_allowlist ids.
18451    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
18452    #[test]
18453    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18454        let (shared, defaults) = station_knobs_fixture();
18455        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18456        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18457        let spec = crate::protocol::SpawnSpec {
18458            target: crate::protocol::SpawnTarget {
18459                node_id: shared.node_id.clone(),
18460                workspace_id: WorkspaceId::new("primary").unwrap(),
18461                worktree_id: None,
18462            },
18463            profile_id: defaults.profile_id.clone(),
18464            expected_profile_revision: defaults.revision.clone(),
18465            overrides: crate::protocol::SpawnOverrides {
18466                network_allowlist: Some(unknown),
18467                browser_profile_id: Some(browser),
18468                ..crate::protocol::SpawnOverrides::default()
18469            },
18470            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18471            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18472                "station-knobs-unknown-allowlist",
18473            )
18474            .unwrap(),
18475            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18476        };
18477        let resolved = spec.resolve(&defaults).unwrap();
18478        let failure = shared
18479            .resolve_environment_profile(&resolved)
18480            .expect_err("unknown allowlist must refuse");
18481        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18482    }
18483
18484    /// Registered network_allowlist (+ browser_profile_id when feature off)
18485    /// echo onto the env-profile receipt. Empty catalog still allows omit
18486    /// (None). Empty/whitespace refuse is type-level. Feature-off
18487    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
18488    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
18489    #[test]
18490    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18491        let (shared, defaults) = station_knobs_fixture();
18492        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18493        #[cfg(not(feature = "dig2-station-probe"))]
18494        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18495        shared
18496            .network_allowlist_catalog
18497            .write()
18498            .unwrap_or_else(|poisoned| poisoned.into_inner())
18499            .insert_id(allowlist.clone())
18500            .unwrap();
18501        let spec = crate::protocol::SpawnSpec {
18502            target: crate::protocol::SpawnTarget {
18503                node_id: shared.node_id.clone(),
18504                workspace_id: WorkspaceId::new("primary").unwrap(),
18505                worktree_id: None,
18506            },
18507            profile_id: defaults.profile_id.clone(),
18508            expected_profile_revision: defaults.revision.clone(),
18509            overrides: crate::protocol::SpawnOverrides {
18510                network_allowlist: Some(allowlist.clone()),
18511                #[cfg(not(feature = "dig2-station-probe"))]
18512                browser_profile_id: Some(browser.clone()),
18513                ..crate::protocol::SpawnOverrides::default()
18514            },
18515            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18516            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18517                .unwrap(),
18518            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18519        };
18520        let resolved = spec.resolve(&defaults).unwrap();
18521        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18522        #[cfg(not(feature = "dig2-station-probe"))]
18523        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18524        let receipt = shared
18525            .resolve_environment_profile(&resolved)
18526            .expect("resolve ok")
18527            .expect("env profile present");
18528        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18529        #[cfg(not(feature = "dig2-station-probe"))]
18530        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18531        #[cfg(feature = "dig2-station-probe")]
18532        assert_eq!(receipt.browser_profile_id, None);
18533        assert_eq!(receipt.profile_id.as_str(), "local-claude");
18534
18535        let omitted = crate::protocol::SpawnSpec {
18536            overrides: crate::protocol::SpawnOverrides::default(),
18537            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18538                .unwrap(),
18539            ..spec
18540        };
18541        let resolved_omit = omitted.resolve(&defaults).unwrap();
18542        let bare = shared
18543            .resolve_environment_profile(&resolved_omit)
18544            .expect("resolve ok")
18545            .expect("env profile present");
18546        assert_eq!(bare.network_allowlist, None);
18547        assert_eq!(bare.browser_profile_id, None);
18548    }
18549
18550    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
18551    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
18552    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
18553    /// unit-tested separately (probe refuses before lease on Linux).
18554    #[cfg(feature = "dig2-station-probe")]
18555    #[test]
18556    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18557        let (shared, defaults) = station_knobs_fixture();
18558        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18559        let spec = crate::protocol::SpawnSpec {
18560            target: crate::protocol::SpawnTarget {
18561                node_id: shared.node_id.clone(),
18562                workspace_id: WorkspaceId::new("primary").unwrap(),
18563                worktree_id: None,
18564            },
18565            profile_id: defaults.profile_id.clone(),
18566            expected_profile_revision: defaults.revision.clone(),
18567            overrides: crate::protocol::SpawnOverrides {
18568                browser_profile_id: Some(browser),
18569                ..crate::protocol::SpawnOverrides::default()
18570            },
18571            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18572            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18573                "station-knobs-probe-refuse",
18574            )
18575            .unwrap(),
18576            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18577        };
18578        let resolved = spec.resolve(&defaults).unwrap();
18579        let failure = shared
18580            .resolve_environment_profile(&resolved)
18581            .expect_err("browser_profile with failing probe must refuse");
18582        #[cfg(windows)]
18583        {
18584            assert!(
18585                matches!(
18586                    failure.code,
18587                    NodeFailureCode::BrowserStationUnreachable
18588                        | NodeFailureCode::BrowserStationProbeUnavailable
18589                ),
18590                "unexpected code {:?}",
18591                failure.code
18592            );
18593        }
18594        #[cfg(not(windows))]
18595        {
18596            assert_eq!(
18597                failure.code,
18598                NodeFailureCode::BrowserStationProbeUnavailable
18599            );
18600        }
18601    }
18602
18603    /// Feature on: omitting browser_profile_id still resolves (no probe).
18604    #[cfg(feature = "dig2-station-probe")]
18605    #[test]
18606    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18607        let (shared, defaults) = station_knobs_fixture();
18608        let spec = crate::protocol::SpawnSpec {
18609            target: crate::protocol::SpawnTarget {
18610                node_id: shared.node_id.clone(),
18611                workspace_id: WorkspaceId::new("primary").unwrap(),
18612                worktree_id: None,
18613            },
18614            profile_id: defaults.profile_id.clone(),
18615            expected_profile_revision: defaults.revision.clone(),
18616            overrides: crate::protocol::SpawnOverrides::default(),
18617            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18618            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18619                "station-knobs-probe-omit",
18620            )
18621            .unwrap(),
18622            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18623        };
18624        let resolved = spec.resolve(&defaults).unwrap();
18625        let receipt = shared
18626            .resolve_environment_profile(&resolved)
18627            .expect("omit browser_profile must not probe")
18628            .expect("env profile present");
18629        assert_eq!(receipt.browser_profile_id, None);
18630    }
18631
18632    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
18633    /// refuses a second holder for the same opaque profile id; release on
18634    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
18635    #[cfg(feature = "dig2-station-probe")]
18636    #[test]
18637    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18638        let (shared, _defaults) = station_knobs_fixture();
18639        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18640        let workspace_id = WorkspaceId::new("primary").unwrap();
18641        let address_a = SessionAddress {
18642            workspace_id: workspace_id.clone(),
18643            session: SessionKey {
18644                instance_id: AgentInstanceId(101),
18645                generation: SessionGeneration(1),
18646            },
18647        };
18648        let address_b = SessionAddress {
18649            workspace_id: workspace_id.clone(),
18650            session: SessionKey {
18651                instance_id: AgentInstanceId(102),
18652                generation: SessionGeneration(1),
18653            },
18654        };
18655        let guard_a = crate::dig2_station_lease::acquire_lease(
18656            &shared.browser_station_leases,
18657            profile.clone(),
18658            address_a.session.instance_id,
18659            1,
18660        )
18661        .expect("first lease");
18662        let busy = crate::dig2_station_lease::acquire_lease(
18663            &shared.browser_station_leases,
18664            profile.clone(),
18665            address_b.session.instance_id,
18666            2,
18667        )
18668        .expect_err("second holder must refuse");
18669        assert!(matches!(
18670            busy,
18671            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18672        ));
18673        // Simulate a bound session so remove_binding clears the lease.
18674        shared.bind_session_with_policy(
18675            &address_a,
18676            ProviderRuntimePolicy::raw_pty(),
18677            None,
18678        );
18679        guard_a.retain();
18680        assert!(shared.remove_binding(&address_a).is_some());
18681        crate::dig2_station_lease::acquire_lease(
18682            &shared.browser_station_leases,
18683            profile,
18684            address_b.session.instance_id,
18685            3,
18686        )
18687        .expect("reacquire after clear")
18688        .retain();
18689        assert!(
18690            crate::dig2_station_lease::release_lease_for_holder(
18691                &shared.browser_station_leases,
18692                address_b.session.instance_id,
18693            )
18694        );
18695    }
18696
18697    /// Feature-off: no browser_station_leases field; stub echo documented in
18698    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
18699    #[cfg(not(feature = "dig2-station-probe"))]
18700    #[test]
18701    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18702        let (shared, defaults) = station_knobs_fixture();
18703        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18704        let spec = crate::protocol::SpawnSpec {
18705            target: crate::protocol::SpawnTarget {
18706                node_id: shared.node_id.clone(),
18707                workspace_id: WorkspaceId::new("primary").unwrap(),
18708                worktree_id: None,
18709            },
18710            profile_id: defaults.profile_id.clone(),
18711            expected_profile_revision: defaults.revision.clone(),
18712            overrides: crate::protocol::SpawnOverrides {
18713                browser_profile_id: Some(browser.clone()),
18714                ..crate::protocol::SpawnOverrides::default()
18715            },
18716            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18717            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18718                .unwrap(),
18719            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18720        };
18721        let resolved = spec.resolve(&defaults).unwrap();
18722        let receipt = shared
18723            .resolve_environment_profile(&resolved)
18724            .expect("stub resolve")
18725            .expect("env profile");
18726        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18727    }
18728
18729
18730    /// Startup file loader registers ids into the empty-default catalog.
18731    /// Feature-off dig2 probe stays stub. Never secrets on C2.
18732    #[test]
18733    fn network_allowlist_catalog_loads_from_temp_file() {
18734        let unique = std::time::SystemTime::now()
18735            .duration_since(std::time::UNIX_EPOCH)
18736            .unwrap()
18737            .as_nanos();
18738        let path = std::env::temp_dir().join(format!(
18739            "gate4agent-node-allowlist-config-{}-{unique}.txt",
18740            std::process::id(),
18741        ));
18742        std::fs::write(
18743            &path,
18744            "# opaque station allowlist ids\negress-default\nlab-egress\n",
18745        )
18746        .unwrap();
18747        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18748            .expect("temp catalog file must load");
18749        let _ = std::fs::remove_file(&path);
18750        assert_eq!(loaded.len(), 2);
18751        let (shared, defaults) = station_knobs_fixture();
18752        {
18753            let mut catalog = shared
18754                .network_allowlist_catalog
18755                .write()
18756                .unwrap_or_else(|poisoned| poisoned.into_inner());
18757            *catalog = loaded;
18758        }
18759        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18760        let spec = crate::protocol::SpawnSpec {
18761            target: crate::protocol::SpawnTarget {
18762                node_id: shared.node_id.clone(),
18763                workspace_id: WorkspaceId::new("primary").unwrap(),
18764                worktree_id: None,
18765            },
18766            profile_id: defaults.profile_id.clone(),
18767            expected_profile_revision: defaults.revision.clone(),
18768            overrides: crate::protocol::SpawnOverrides {
18769                network_allowlist: Some(allowlist.clone()),
18770                ..crate::protocol::SpawnOverrides::default()
18771            },
18772            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18773            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18774                "station-knobs-catalog-file",
18775            )
18776            .unwrap(),
18777            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18778        };
18779        let resolved = spec.resolve(&defaults).unwrap();
18780        let receipt = shared
18781            .resolve_environment_profile(&resolved)
18782            .expect("registered via file load")
18783            .expect("env profile present");
18784        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18785    }
18786
18787    /// Snapshot launch inventory echoes registered catalog ids (opaque).
18788    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
18789    #[test]
18790    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18791        let (shared, _defaults) = station_knobs_fixture();
18792        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18793        shared
18794            .network_allowlist_catalog
18795            .write()
18796            .unwrap_or_else(|poisoned| poisoned.into_inner())
18797            .insert_id(allowlist.clone())
18798            .unwrap();
18799        let snapshot = shared.snapshot();
18800        let inventory = snapshot
18801            .launch_inventory
18802            .expect("launch inventory present");
18803        let ids = inventory
18804            .network_allowlists
18805            .expect("non-empty catalog surfaces ids");
18806        assert_eq!(ids, vec![allowlist]);
18807    }
18808
18809    /// Track B: registered id with provider_native codex_network_access refuses
18810    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
18811    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
18812    #[test]
18813    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18814        let (shared, defaults) = station_knobs_fixture();
18815        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18816        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18817            id: allowlist.clone(),
18818            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18819                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18820                peer: "127.0.0.1:443".to_owned(),
18821            }],
18822            provider_native: Some(
18823                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18824                    codex_network_access: Some(true),
18825                },
18826            ),
18827        };
18828        shared
18829            .network_allowlist_catalog
18830            .write()
18831            .unwrap_or_else(|poisoned| poisoned.into_inner())
18832            .insert(entry)
18833            .unwrap();
18834        let spec = crate::protocol::SpawnSpec {
18835            target: crate::protocol::SpawnTarget {
18836                node_id: shared.node_id.clone(),
18837                workspace_id: WorkspaceId::new("primary").unwrap(),
18838                worktree_id: None,
18839            },
18840            profile_id: defaults.profile_id.clone(),
18841            expected_profile_revision: defaults.revision.clone(),
18842            overrides: crate::protocol::SpawnOverrides {
18843                network_allowlist: Some(allowlist),
18844                ..crate::protocol::SpawnOverrides::default()
18845            },
18846            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18847            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18848                "station-knobs-unsupported-mapping",
18849            )
18850            .unwrap(),
18851            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18852        };
18853        let resolved = spec.resolve(&defaults).unwrap();
18854        assert_eq!(resolved.provider.as_str(), "claude");
18855        let failure = shared
18856            .resolve_environment_profile(&resolved)
18857            .expect_err("claude + codex_network_access must refuse");
18858        assert_eq!(
18859            failure.code,
18860            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18861        );
18862    }
18863
18864    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
18865    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
18866    /// Never secrets on C2.
18867    #[test]
18868    fn resolve_codex_network_access_overlay_moderate_only() {
18869        let (shared, defaults) = station_knobs_fixture();
18870        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18871        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18872            id: allowlist.clone(),
18873            permits: Vec::new(),
18874            provider_native: Some(
18875                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18876                    codex_network_access: Some(true),
18877                },
18878            ),
18879        };
18880        shared
18881            .network_allowlist_catalog
18882            .write()
18883            .unwrap_or_else(|poisoned| poisoned.into_inner())
18884            .insert(entry)
18885            .unwrap();
18886
18887        let overlay = shared
18888            .resolve_codex_network_access_overlay_args(
18889                &agent("codex"),
18890                SessionMode::Pty,
18891                ApprovalLevel::Moderate,
18892                Some(&allowlist),
18893            )
18894            .expect("Moderate Codex maps network_access");
18895        assert_eq!(
18896            overlay,
18897            vec![
18898                std::ffi::OsString::from("-c"),
18899                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18900            ]
18901        );
18902
18903        let acp = shared
18904            .resolve_codex_network_access_overlay_args(
18905                &agent("codex"),
18906                SessionMode::Acp,
18907                ApprovalLevel::Moderate,
18908                Some(&allowlist),
18909            )
18910            .unwrap();
18911        assert_eq!(acp, overlay);
18912
18913        let inline = shared
18914            .resolve_codex_network_access_overlay_args(
18915                &agent("codex"),
18916                SessionMode::Inline,
18917                ApprovalLevel::Moderate,
18918                Some(&allowlist),
18919            )
18920            .unwrap_err();
18921        assert_eq!(
18922            inline.code,
18923            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18924        );
18925
18926        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18927            let spec = crate::protocol::SpawnSpec {
18928                target: crate::protocol::SpawnTarget {
18929                    node_id: shared.node_id.clone(),
18930                    workspace_id: WorkspaceId::new("primary").unwrap(),
18931                    worktree_id: None,
18932                },
18933                profile_id: defaults.profile_id.clone(),
18934                expected_profile_revision: defaults.revision.clone(),
18935                overrides: crate::protocol::SpawnOverrides {
18936                    provider: crate::protocol::SpawnOverride::Set {
18937                        value: agent("codex"),
18938                    },
18939                    approval_level: Some(level),
18940                    network_allowlist: Some(allowlist.clone()),
18941                    ..crate::protocol::SpawnOverrides::default()
18942                },
18943                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18944                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18945                    "codex-net-refuse-{}",
18946                    match level {
18947                        ApprovalLevel::ReadOnly => "ro",
18948                        ApprovalLevel::FullAuto => "fa",
18949                        _ => "other",
18950                    }
18951                ))
18952                .unwrap(),
18953                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18954            };
18955            let resolved = spec.resolve(&defaults).unwrap();
18956            let failure = shared
18957                .resolve_environment_profile(&resolved)
18958                .expect_err("RO/FullAuto must refuse");
18959            assert_eq!(
18960                failure.code,
18961                NodeFailureCode::UnsupportedNetworkAllowlistMapping
18962            );
18963        }
18964    }
18965
18966    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
18967    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
18968    /// Never secrets on C2; does not invent provider_native Claude keys.
18969    #[test]
18970    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18971        let (shared, _defaults) = station_knobs_fixture();
18972        let allowlist =
18973            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18974        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18975            id: allowlist.clone(),
18976            permits: vec![
18977                crate::network_allowlist_catalog::NetworkPermitSketch {
18978                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18979                    peer: "github.com:443".to_owned(),
18980                },
18981                crate::network_allowlist_catalog::NetworkPermitSketch {
18982                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18983                    peer: "*.npmjs.org".to_owned(),
18984                },
18985            ],
18986            provider_native: None,
18987        };
18988        shared
18989            .network_allowlist_catalog
18990            .write()
18991            .unwrap_or_else(|poisoned| poisoned.into_inner())
18992            .insert(entry)
18993            .unwrap();
18994
18995        let unique = std::time::SystemTime::now()
18996            .duration_since(std::time::UNIX_EPOCH)
18997            .unwrap()
18998            .as_nanos();
18999        let provider_home = std::env::temp_dir().join(format!(
19000            "gate4agent-claude-provider-home-{}-{unique}",
19001            std::process::id(),
19002        ));
19003        std::fs::create_dir_all(&provider_home).unwrap();
19004
19005        let overlay = shared
19006            .resolve_network_allowlist_overlay_args(
19007                &agent("claude"),
19008                SessionMode::Pty,
19009                ApprovalLevel::Moderate,
19010                Some(&allowlist),
19011                Some(provider_home.as_path()),
19012            )
19013            .expect("Claude PTY settings overlay on unix");
19014        let settings_path = provider_home.join(
19015            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19016        );
19017        assert_eq!(
19018            overlay,
19019            vec![
19020                std::ffi::OsString::from("--settings"),
19021                std::ffi::OsString::from(settings_path.as_os_str()),
19022            ]
19023        );
19024        let body = std::fs::read_to_string(&settings_path).unwrap();
19025        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19026        assert_eq!(value["sandbox"]["enabled"], true);
19027        assert_eq!(
19028            value["sandbox"]["network"]["allowedDomains"],
19029            serde_json::json!(["github.com", "*.npmjs.org"])
19030        );
19031        assert!(settings_path.starts_with(&provider_home));
19032
19033        let acp = shared
19034            .resolve_network_allowlist_overlay_args(
19035                &agent("claude"),
19036                SessionMode::Acp,
19037                ApprovalLevel::Moderate,
19038                Some(&allowlist),
19039                Some(provider_home.as_path()),
19040            )
19041            .expect_err("Claude ACP must refuse settings overlay");
19042        assert_eq!(
19043            acp.code,
19044            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19045        );
19046
19047        let no_home = shared
19048            .resolve_network_allowlist_overlay_args(
19049                &agent("claude"),
19050                SessionMode::Pty,
19051                ApprovalLevel::Moderate,
19052                Some(&allowlist),
19053                None,
19054            )
19055            .expect_err("Claude overlay requires ProviderHome");
19056        assert_eq!(
19057            no_home.code,
19058            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19059        );
19060
19061        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19062        shared
19063            .network_allowlist_catalog
19064            .write()
19065            .unwrap_or_else(|poisoned| poisoned.into_inner())
19066            .insert_id(bare.clone())
19067            .unwrap();
19068        let empty = shared
19069            .resolve_network_allowlist_overlay_args(
19070                &agent("claude"),
19071                SessionMode::Pty,
19072                ApprovalLevel::Moderate,
19073                Some(&bare),
19074                Some(provider_home.as_path()),
19075            )
19076            .unwrap();
19077        assert!(empty.is_empty());
19078
19079        let _ = std::fs::remove_dir_all(&provider_home);
19080    }
19081
19082    /// Kimi / Grok with permits stay without invented argv overlays.
19083    #[test]
19084    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19085        let (shared, _defaults) = station_knobs_fixture();
19086        let allowlist =
19087            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19088        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19089            id: allowlist.clone(),
19090            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19091                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19092                peer: "example.com:443".to_owned(),
19093            }],
19094            provider_native: None,
19095        };
19096        shared
19097            .network_allowlist_catalog
19098            .write()
19099            .unwrap_or_else(|poisoned| poisoned.into_inner())
19100            .insert(entry)
19101            .unwrap();
19102        for provider in ["kimi", "grok"] {
19103            let overlay = shared
19104                .resolve_network_allowlist_overlay_args(
19105                    &agent(provider),
19106                    SessionMode::Pty,
19107                    ApprovalLevel::Moderate,
19108                    Some(&allowlist),
19109                    None,
19110                )
19111                .unwrap();
19112            assert!(
19113                overlay.is_empty(),
19114                "{provider} must not invent network argv from permits"
19115            );
19116        }
19117    }
19118
19119    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
19120    #[test]
19121    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19122        let unique = std::time::SystemTime::now()
19123            .duration_since(std::time::UNIX_EPOCH)
19124            .unwrap()
19125            .as_nanos();
19126        let path = std::env::temp_dir().join(format!(
19127            "gate4agent-node-allowlist-json-{}-{unique}.json",
19128            std::process::id(),
19129        ));
19130        std::fs::write(
19131            &path,
19132            r#"{
19133              "schema_version": 2,
19134              "entries": [
19135                {
19136                  "id": "egress-default",
19137                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19138                },
19139                { "id": "lab-egress" }
19140              ]
19141            }"#,
19142        )
19143        .unwrap();
19144        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19145            .expect("json v2 catalog must load");
19146        let _ = std::fs::remove_file(&path);
19147        assert_eq!(loaded.len(), 2);
19148        let entry = loaded
19149            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19150            .unwrap();
19151        assert_eq!(entry.permits.len(), 1);
19152        let (shared, defaults) = station_knobs_fixture();
19153        {
19154            let mut catalog = shared
19155                .network_allowlist_catalog
19156                .write()
19157                .unwrap_or_else(|poisoned| poisoned.into_inner());
19158            *catalog = loaded;
19159        }
19160        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19161        let spec = crate::protocol::SpawnSpec {
19162            target: crate::protocol::SpawnTarget {
19163                node_id: shared.node_id.clone(),
19164                workspace_id: WorkspaceId::new("primary").unwrap(),
19165                worktree_id: None,
19166            },
19167            profile_id: defaults.profile_id.clone(),
19168            expected_profile_revision: defaults.revision.clone(),
19169            overrides: crate::protocol::SpawnOverrides {
19170                network_allowlist: Some(allowlist.clone()),
19171                ..crate::protocol::SpawnOverrides::default()
19172            },
19173            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19174            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19175                "station-knobs-catalog-json",
19176            )
19177            .unwrap(),
19178            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19179        };
19180        let resolved = spec.resolve(&defaults).unwrap();
19181        let receipt = shared
19182            .resolve_environment_profile(&resolved)
19183            .expect("permits-only entry ok for claude")
19184            .expect("env profile present");
19185        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19186        let inventory = shared
19187            .snapshot()
19188            .launch_inventory
19189            .expect("launch inventory present");
19190        let ids = inventory.network_allowlists.expect("ids surface");
19191        assert_eq!(
19192            ids,
19193            vec![
19194                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19195                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19196            ]
19197        );
19198    }
19199
19200
19201}
19202
19203
19204#[cfg(test)]
19205mod workspace_environment_collision_tests {
19206    use super::*;
19207    use crate::session_environment::{
19208        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19209    };
19210    use gate4agent_catalog::EnvMutation;
19211    use gate4agent_runtime_native::{
19212        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19213        NativeLaunchProfileId,
19214    };
19215    use std::ffi::OsString;
19216    use std::sync::Arc;
19217
19218    struct EmptyEnvironmentResolver;
19219
19220    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19221        fn resolve_child_environment(
19222            &self,
19223        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19224            Ok(vec![EnvMutation {
19225                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19226                value: None,
19227            }])
19228        }
19229    }
19230
19231    fn agent(value: &str) -> AgentId {
19232        AgentId::new(value).unwrap()
19233    }
19234
19235    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
19236    fn claude_config_dir_collision_fixture() -> (
19237        NodeShared,
19238        ResolvedEnvironmentProfileReceipt,
19239        ResolvedEnvironmentProfileReceipt,
19240    ) {
19241        let catalog = active_registry().unwrap();
19242        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19243        let control = runtime.native_launch_profile_control();
19244        let workspace = WorkspaceConfig::new(
19245            WorkspaceId::new("primary").unwrap(),
19246            std::env::current_dir().unwrap(),
19247        )
19248        .unwrap();
19249        let shared = NodeShared::new_with_incarnation(
19250            handle,
19251            "fixture-token".to_owned(),
19252            NodeId::new("node-env-collision-test").unwrap(),
19253            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19254            vec![workspace],
19255            vec![agent("claude")],
19256            ProviderRuntimeStatuses::default(),
19257            None,
19258            Vec::new(),
19259            Vec::new(),
19260            SpawnProfileRegistry::default(),
19261            Some(control),
19262            None,
19263            None,
19264            None,
19265            Vec::new(),
19266            Vec::new(),
19267            Vec::new(),
19268            Vec::new(),
19269            None,
19270            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19271        );
19272
19273        let mut receipts = Vec::new();
19274        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19275            let profile_id =
19276                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19277            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19278                "claude-config-dir-{suffix}-r1"
19279            ))
19280            .unwrap();
19281            let materialization = NodeSessionMaterializationProfile::new(
19282                Vec::new(),
19283                vec![NodeSessionPathBinding::new(
19284                    "CLAUDE_CONFIG_DIR",
19285                    NodeSessionPathClass::ProviderHome,
19286                )
19287                .unwrap()],
19288                Vec::new(),
19289            )
19290            .unwrap();
19291            let node_profile = NodeEnvironmentProfile::new_with_materialization(
19292                profile_id.clone(),
19293                profile_revision.clone(),
19294                agent("claude"),
19295                [NativeLaunchProfile::new(
19296                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19297                        .unwrap(),
19298                    agent("claude"),
19299                    TransportKind::Pty,
19300                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19301                    Arc::new(EmptyEnvironmentResolver),
19302                )
19303                .unwrap()],
19304                Some(materialization),
19305            )
19306            .unwrap();
19307            let (binding, native_profiles, materialization) = node_profile.into_parts();
19308            for native_profile in native_profiles {
19309                runtime.upsert_native_launch_profile(native_profile).unwrap();
19310            }
19311            shared
19312                .environment_profiles
19313                .write()
19314                .unwrap_or_else(|poisoned| poisoned.into_inner())
19315                .insert(binding.id.clone(), binding);
19316            shared
19317                .environment_materialization_profiles
19318                .write()
19319                .unwrap_or_else(|poisoned| poisoned.into_inner())
19320                .insert(profile_id.clone(), materialization.unwrap());
19321            receipts.push(ResolvedEnvironmentProfileReceipt {
19322                profile_id,
19323                profile_revision,
19324                network_allowlist: None,
19325                browser_profile_id: None,
19326            });
19327        }
19328        let profile_b = receipts.pop().unwrap();
19329        let profile_a = receipts.pop().unwrap();
19330        (shared, profile_a, profile_b)
19331    }
19332
19333    #[test]
19334    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19335        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19336        let primary = WorkspaceId::new("primary").unwrap();
19337        let worktree = WorkspaceId::new("worktree-b").unwrap();
19338        let address = SessionAddress {
19339            workspace_id: primary.clone(),
19340            session: SessionKey {
19341                instance_id: AgentInstanceId(41),
19342                generation: SessionGeneration(1),
19343            },
19344        };
19345        shared.bind_session_with_policy(
19346            &address,
19347            ProviderRuntimePolicy::raw_pty(),
19348            Some(profile_a.clone()),
19349        );
19350
19351        let refused = shared
19352            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19353            .unwrap_err();
19354        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19355
19356        // Some vs None on the same cwd is also a collision.
19357        let refused_none = shared
19358            .ensure_workspace_environment_profile_compatible(&primary, None)
19359            .unwrap_err();
19360        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19361
19362        // Same profile on the same workspace remains allowed.
19363        shared
19364            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19365            .unwrap();
19366
19367        // Distinct workspace id (managed-worktree parallel path) allows profile B.
19368        shared
19369            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19370            .unwrap();
19371    }
19372
19373    #[tokio::test]
19374    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19375        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19376        let primary = WorkspaceId::new("primary").unwrap();
19377        let address = SessionAddress {
19378            workspace_id: primary.clone(),
19379            session: SessionKey {
19380                instance_id: AgentInstanceId(41),
19381                generation: SessionGeneration(1),
19382            },
19383        };
19384        shared.bind_session_with_policy(
19385            &address,
19386            ProviderRuntimePolicy::raw_pty(),
19387            Some(profile_a),
19388        );
19389
19390        let failure = shared
19391            .spawn_session_with_deadline(
19392                primary,
19393                agent("claude"),
19394                SessionMode::Pty,
19395                gate4agent_types::TerminalSize {
19396                    rows: 24,
19397                    columns: 80,
19398                },
19399                None,
19400                Some(profile_b),
19401                None,
19402                None,
19403                None,
19404                None,
19405                SpawnRecordPolicy::ProviderIdentityOnly,
19406                Some(Instant::now() + Duration::from_secs(30)),
19407                &[],
19408                None,
19409                ApprovalLevel::default(),
19410                None,
19411                None,
19412            )
19413            .await
19414            .unwrap_err();
19415        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19416        // Guard runs before instance id mint / bind.
19417        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19418        assert!(shared.handle.snapshot().sessions.is_empty());
19419    }
19420}
19421
19422#[cfg(test)]
19423mod spawn_characterization_tests {
19424    use super::*;
19425    use crate::session_environment::{
19426        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19427        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19428        NodeSessionPathClass,
19429    };
19430    use gate4agent_catalog::EnvMutation;
19431    use gate4agent_runtime_native::{
19432        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19433        NativeLaunchProfile, NativeLaunchProfileId,
19434    };
19435    use std::ffi::OsString;
19436    use std::sync::Arc;
19437
19438    struct EmptyEnvironmentResolver;
19439
19440    struct FixtureSecretResolver {
19441        deny: Arc<AtomicBool>,
19442    }
19443
19444    impl NodeSecretResolver for FixtureSecretResolver {
19445        fn resolve(
19446            &self,
19447            _reference: &NodeSecretReference,
19448        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19449            if self.deny.load(Ordering::Acquire) {
19450                Err(NodeSecretResolveError::Denied)
19451            } else {
19452                NodeSecretValue::text("fixture-secret")
19453                    .map_err(|_| NodeSecretResolveError::Unavailable)
19454            }
19455        }
19456    }
19457
19458    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19459        fn resolve_child_environment(
19460            &self,
19461        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19462            Ok(vec![EnvMutation {
19463                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19464                value: None,
19465            }])
19466        }
19467    }
19468
19469    fn agent(value: &str) -> AgentId {
19470        AgentId::new(value).unwrap()
19471    }
19472
19473    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19474        let unique = std::time::SystemTime::now()
19475            .duration_since(std::time::UNIX_EPOCH)
19476            .unwrap()
19477            .as_nanos();
19478        std::env::temp_dir().join(format!(
19479            "gate4agent-node-{label}-{}-{unique}",
19480            std::process::id(),
19481        ))
19482    }
19483
19484    fn materialization_test_shared(
19485        deny_secret: bool,
19486    ) -> (
19487        NodeShared,
19488        NativeRuntime,
19489        ResolvedEnvironmentProfileReceipt,
19490        PathBuf,
19491        Arc<AtomicBool>,
19492    ) {
19493        let workspace = WorkspaceConfig::new(
19494            WorkspaceId::new("primary").unwrap(),
19495            std::env::current_dir().unwrap(),
19496        )
19497        .unwrap();
19498        materialization_test_shared_with_workspace(deny_secret, workspace)
19499    }
19500
19501    fn materialization_test_shared_with_workspace(
19502        deny_secret: bool,
19503        workspace: WorkspaceConfig,
19504    ) -> (
19505        NodeShared,
19506        NativeRuntime,
19507        ResolvedEnvironmentProfileReceipt,
19508        PathBuf,
19509        Arc<AtomicBool>,
19510    ) {
19511        let root = temporary_workspace_root(if deny_secret {
19512            "materialization-denied"
19513        } else {
19514            "materialization-ready"
19515        });
19516        std::fs::create_dir_all(&root).unwrap();
19517        let materialization_root = root.join("session-environments");
19518        let deny = Arc::new(AtomicBool::new(deny_secret));
19519        let resolver = Arc::new(FixtureSecretResolver {
19520            deny: Arc::clone(&deny),
19521        });
19522        let materializer = SessionEnvironmentMaterializer::new(
19523            materialization_root,
19524            resolver,
19525        )
19526        .unwrap();
19527        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19528        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19529            "materialized-claude-r1",
19530        )
19531        .unwrap();
19532        let materialization = NodeSessionMaterializationProfile::new(
19533            vec![NodeSessionEnvironmentMutation::SetSecret {
19534                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19535                reference: NodeSecretReference::new("fixture-token").unwrap(),
19536            }],
19537            vec![NodeSessionPathBinding::new(
19538                "GATE4AGENT_PROVIDER_HOME",
19539                NodeSessionPathClass::ProviderHome,
19540            )
19541            .unwrap()],
19542            vec![NodeSessionFile::secret(
19543                NodeSessionPathClass::Config,
19544                "auth/token",
19545                NodeSecretReference::new("fixture-token").unwrap(),
19546            )
19547            .unwrap()],
19548        )
19549        .unwrap();
19550        let catalog = active_registry().unwrap();
19551        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19552        let control = runtime.native_launch_profile_control();
19553        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19554            profile_id.clone(),
19555            profile_revision.clone(),
19556            agent("claude"),
19557            [NativeLaunchProfile::new(
19558                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19559                agent("claude"),
19560                TransportKind::Pty,
19561                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19562                Arc::new(EmptyEnvironmentResolver),
19563            )
19564            .unwrap()],
19565            Some(materialization),
19566        )
19567        .unwrap();
19568        let (binding, native_profiles, materialization) = node_profile.into_parts();
19569        for native_profile in native_profiles {
19570            runtime.upsert_native_launch_profile(native_profile).unwrap();
19571        }
19572        let shared = NodeShared::new_with_incarnation(
19573            handle,
19574            "fixture-token".to_owned(),
19575            NodeId::new("node-materialization-test").unwrap(),
19576            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19577            vec![workspace],
19578            vec![agent("claude")],
19579            ProviderRuntimeStatuses::default(),
19580            None,
19581            Vec::new(),
19582            Vec::new(),
19583            SpawnProfileRegistry::default(),
19584            Some(control),
19585            None,
19586            Some(materializer),
19587            None,
19588            Vec::new(),
19589            Vec::new(),
19590            Vec::new(),
19591            Vec::new(),
19592            None,
19593            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19594        );
19595        shared
19596            .environment_profiles
19597            .write()
19598            .unwrap_or_else(|poisoned| poisoned.into_inner())
19599            .insert(binding.id.clone(), binding);
19600        shared
19601            .environment_materialization_profiles
19602            .write()
19603            .unwrap_or_else(|poisoned| poisoned.into_inner())
19604            .insert(profile_id.clone(), materialization.unwrap());
19605        (
19606            shared,
19607            runtime,
19608            ResolvedEnvironmentProfileReceipt {
19609                profile_id,
19610                profile_revision,
19611                network_allowlist: None,
19612                browser_profile_id: None,
19613            },
19614            root,
19615            deny,
19616        )
19617    }
19618
19619
19620    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
19621
19622    async fn characterization_spawn(
19623        shared: &NodeShared,
19624        mode: SessionMode,
19625        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19626        required: &[ProviderRuntimeCapability],
19627        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19628    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19629        shared
19630            .spawn_session_with_deadline(
19631                WorkspaceId::new("primary").unwrap(),
19632                agent("claude"),
19633                mode,
19634                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19635                None,
19636                environment_profile,
19637                None,
19638                None,
19639                None,
19640                Some(ProviderRuntimePolicy::raw_pty()),
19641                SpawnRecordPolicy::ProviderIdentityOnly,
19642                Some(Instant::now() + Duration::from_secs(30)),
19643                required,
19644                harness_mcp,
19645                ApprovalLevel::default(),
19646                None,
19647                None,
19648            )
19649            .await
19650    }
19651
19652    fn assert_no_session_state(shared: &NodeShared) {
19653        assert!(shared.handle.snapshot().sessions.is_empty());
19654        assert!(shared
19655            .session_bindings
19656            .lock()
19657            .unwrap_or_else(|poisoned| poisoned.into_inner())
19658            .is_empty());
19659        assert!(shared
19660            .materializations
19661            .lock()
19662            .unwrap_or_else(|poisoned| poisoned.into_inner())
19663            .is_empty());
19664    }
19665
19666    #[tokio::test]
19667    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19668        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19669        let error = characterization_spawn(
19670            &shared,
19671            SessionMode::Pty,
19672            None,
19673            &[ProviderRuntimeCapability::SemanticReadiness],
19674            None,
19675        )
19676        .await
19677        .unwrap_err();
19678        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19679        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19680        assert_no_session_state(&shared);
19681        drop(shared);
19682        drop(runtime);
19683        std::fs::remove_dir_all(root).unwrap();
19684    }
19685
19686    #[tokio::test]
19687    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19688        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19689        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19690            .await
19691            .unwrap_err();
19692        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19693        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19694        assert_no_session_state(&shared);
19695        drop(shared);
19696        drop(runtime);
19697        std::fs::remove_dir_all(root).unwrap();
19698    }
19699
19700    #[test]
19701    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19702        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19703        let address = SessionAddress {
19704            workspace_id: WorkspaceId::new("primary").unwrap(),
19705            session: SessionKey {
19706                instance_id: AgentInstanceId(77),
19707                generation: SessionGeneration(1),
19708            },
19709        };
19710        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19711        let removed = shared.remove_binding(&address).expect("binding removed");
19712        assert!(removed.environment_profile.is_some());
19713        assert!(shared.remove_binding(&address).is_none());
19714        assert_no_session_state(&shared);
19715        drop(shared);
19716        drop(runtime);
19717        std::fs::remove_dir_all(root).unwrap();
19718    }
19719
19720    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19721        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19722        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19723        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19724            .expect("review current test executable as fixture helper");
19725        let registry = HarnessMcpProxyRegistry::new(helper);
19726        let spec = crate::protocol::SpawnSpec {
19727            target: crate::protocol::SpawnTarget {
19728                node_id: NodeId::new("node-characterization").unwrap(),
19729                workspace_id: WorkspaceId::new("primary").unwrap(),
19730                worktree_id: None,
19731            },
19732            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19733            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19734            overrides: crate::protocol::SpawnOverrides::default(),
19735            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19736            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19737            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19738        };
19739        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19740        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19741        let launch = HarnessMcpLaunchV1 {
19742            server_name: "fixture-harness".to_owned(),
19743            args: vec!["--session-proxy".to_owned()],
19744            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19745            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19746            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19747            trace: None,
19748            scrub_env: Vec::new(),
19749        };
19750        let expires = unix_time_ms().saturating_add(30_000);
19751        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19752        registry
19753            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19754            .unwrap()
19755    }
19756
19757    #[tokio::test]
19758    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19759        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19760        let prepared = characterization_prepared_harness("claude").await;
19761        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19762            .await
19763            .unwrap_err();
19764        // Inline is stopped by runtime admission before the harness check runs.
19765        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19766        let mismatched = characterization_prepared_harness("codex").await;
19767        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19768            .await
19769            .unwrap_err();
19770        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19771        assert_no_session_state(&shared);
19772        drop(shared);
19773        drop(runtime);
19774        std::fs::remove_dir_all(root).unwrap();
19775    }
19776
19777    #[tokio::test]
19778    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19779        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19780        shared.native_launch_profile_control = None;
19781        let prepared = characterization_prepared_harness("claude").await;
19782        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19783            .await
19784            .unwrap_err();
19785        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19786        assert_no_session_state(&shared);
19787        drop(shared);
19788        drop(runtime);
19789        std::fs::remove_dir_all(root).unwrap();
19790    }
19791}
19792
19793#[cfg(all(test, windows))]
19794mod tests {
19795    use super::*;
19796    use gate4agent_types::ProviderActivity;
19797    use crate::session_environment::{
19798        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19799        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19800        NodeSessionPathClass,
19801    };
19802    use gate4agent_catalog::EnvMutation;
19803    use gate4agent_runtime_native::{
19804        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19805        NativeLaunchProfile, NativeLaunchProfileId,
19806    };
19807    use std::ffi::OsString;
19808    use std::sync::Arc;
19809
19810    struct EmptyEnvironmentResolver;
19811
19812    struct FixtureSecretResolver {
19813        deny: Arc<AtomicBool>,
19814    }
19815
19816    impl NodeSecretResolver for FixtureSecretResolver {
19817        fn resolve(
19818            &self,
19819            _reference: &NodeSecretReference,
19820        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19821            if self.deny.load(Ordering::Acquire) {
19822                Err(NodeSecretResolveError::Denied)
19823            } else {
19824                NodeSecretValue::text("fixture-secret")
19825                    .map_err(|_| NodeSecretResolveError::Unavailable)
19826            }
19827        }
19828    }
19829
19830    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19831        fn resolve_child_environment(
19832            &self,
19833        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19834            Ok(vec![EnvMutation {
19835                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19836                value: None,
19837            }])
19838        }
19839    }
19840
19841    fn agent(value: &str) -> AgentId {
19842        AgentId::new(value).unwrap()
19843    }
19844
19845    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19846        let catalog = active_registry().unwrap();
19847        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19848        let workspace = WorkspaceConfig::new(
19849            WorkspaceId::new("primary").unwrap(),
19850            std::env::current_dir().unwrap(),
19851        )
19852        .unwrap()
19853        .with_worktree_service_mode(mode);
19854        NodeShared::new(
19855            handle,
19856            "fixture-token".to_owned(),
19857            NodeId::new("node-terminal-test").unwrap(),
19858            vec![workspace],
19859            vec![agent("claude")],
19860        )
19861    }
19862
19863    fn terminal_test_shared() -> NodeShared {
19864        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19865    }
19866
19867    #[cfg(feature = "fixture")]
19868    #[test]
19869    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19870        let probe = SpawnManagedWorktreeV2FailureProbe::default();
19871        assert_eq!(probe.latest_code(), None);
19872
19873        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19874        assert_eq!(
19875            probe.latest_code(),
19876            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19877        );
19878
19879        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19880        assert_eq!(
19881            probe.latest_code(),
19882            Some(NodeFailureCode::SpawnDeadlineExceeded),
19883        );
19884
19885        probe.record(None);
19886        assert_eq!(probe.latest_code(), None);
19887    }
19888
19889    #[test]
19890    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19891        let source = temporary_workspace_root("launch-inventory-source");
19892        let allocation = temporary_workspace_root("launch-inventory-allocation");
19893        std::fs::create_dir_all(&source).unwrap();
19894        std::fs::create_dir_all(&allocation).unwrap();
19895        let workspace_id = WorkspaceId::new("primary").unwrap();
19896        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19897            .unwrap()
19898            .with_worktree_service_mode(WorktreeServiceMode::Managed);
19899        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19900            workspace = workspace
19901                .with_managed_worktree_profile(
19902                    ManagedWorktreeProfile::new(
19903                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19904                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19905                        &allocation,
19906                        "gate4agent",
19907                        "HEAD",
19908                        ManagedWorktreeRetention::RemoveWhenReleased,
19909                    )
19910                    .unwrap(),
19911                )
19912                .unwrap();
19913        }
19914        let overflow = workspace.clone().with_managed_worktree_profile(
19915            ManagedWorktreeProfile::new(
19916                WorktreeProfileId::new("overflow").unwrap(),
19917                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19918                &allocation,
19919                "gate4agent",
19920                "HEAD",
19921                ManagedWorktreeRetention::Retain,
19922            )
19923            .unwrap(),
19924        );
19925        assert!(matches!(
19926            overflow,
19927            Err(NodeServerError::ManagedWorktreeProfileCapacity {
19928                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19929                ..
19930            }),
19931        ));
19932
19933        let catalog = active_registry().unwrap();
19934        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19935        let shared = NodeShared::new(
19936            handle,
19937            "fixture-token".to_owned(),
19938            NodeId::new("node-launch-inventory").unwrap(),
19939            vec![workspace],
19940            vec![agent("claude")],
19941        );
19942        let snapshot = shared.snapshot();
19943        let launch = snapshot.launch_inventory.as_ref().unwrap();
19944        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19945        assert!(launch.bundles.as_ref().unwrap().is_empty());
19946        let profiles = snapshot.workspaces[0]
19947            .managed_worktree_profiles
19948            .as_ref()
19949            .unwrap();
19950        assert_eq!(
19951            snapshot.workspaces[0].worktree_service_mode,
19952            Some(WorktreeServiceMode::Managed),
19953        );
19954        assert_eq!(
19955            profiles.profiles.len(),
19956            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19957        );
19958        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19959        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19960            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19961        }
19962        let legacy = snapshot_for_wire(
19963            &shared,
19964            true,
19965            true,
19966            false,
19967            false,
19968            true,
19969            false,
19970            true,
19971            false,
19972            true,
19973        );
19974        assert!(legacy.launch_inventory.is_none());
19975        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19976
19977        drop(shared);
19978        std::fs::remove_dir_all(source).unwrap();
19979        std::fs::remove_dir_all(allocation).unwrap();
19980    }
19981
19982    #[test]
19983    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19984        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19985            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19986        }));
19987        let source = gate4agent_types::ProviderSource {
19988            family: AdapterFamily::PtySemantic,
19989            binding: AdapterBinding::new(
19990                gate4agent_types::AdapterId::new("claude").unwrap(),
19991                "fixture/v1",
19992                gate4agent_types::AdapterVerification::SyntheticFixture,
19993            )
19994            .unwrap(),
19995        };
19996        let provider = ProviderSnapshot {
19997            sequence: 17,
19998            session: Some(ProviderSessionIdentity {
19999                key: ProviderSessionKey::SessionId,
20000                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20001                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20002            }),
20003            model: Some("PRIVATE_MODEL".to_owned()),
20004            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20005            completed_turns: 3,
20006            usage: gate4agent_types::TokenUsage {
20007                input_tokens: 10,
20008                output_tokens: 20,
20009                cache_read_tokens: 30,
20010                cache_write_tokens: 40,
20011                reasoning_tokens: 50,
20012                context_window: Some(200_000),
20013            },
20014            lead_activity: ProviderActivity::WaitingForInput,
20015            activity: ProviderActivity::WaitingForInput,
20016            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20017            active_tools: (0..9)
20018                .map(|index| gate4agent_types::ActiveProviderTool {
20019                    id: format!("private-tool-id-{index}"),
20020                    name: if index == 0 {
20021                        format!("  Read\u{0000}{}", "x".repeat(80))
20022                    } else {
20023                        format!("Tool-{index}")
20024                    },
20025                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20026                })
20027                .collect(),
20028            interactions: vec![gate4agent_types::ProviderInteraction {
20029                id: gate4agent_types::ProviderInteractionId(17),
20030                source: source.clone(),
20031                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20032                interaction_kind: ProviderInteractionKind::Approval,
20033                tool_name: " Approve\u{0007}Tool ".to_owned(),
20034                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20035                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20036                resume_lead_activity: Some(ProviderActivity::Working),
20037                status: ProviderInteractionStatus::Pending,
20038            }],
20039            subagents: vec![gate4agent_types::ProviderSubagent {
20040                source: source.clone(),
20041                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20042                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20043                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20044            }],
20045            sources: vec![gate4agent_types::ProviderSourceCursor {
20046                source,
20047                sequence: 17,
20048                gap_count: 2,
20049                stale: true,
20050            }],
20051            last_event: Some(ProviderEvent::Error {
20052                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20053            }),
20054            gap_count: 2,
20055            stale: true,
20056        };
20057        let address = terminal_address(3);
20058        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20059        assert_eq!(entry.address, address);
20060        assert_eq!(entry.progress.provider_sequence, 17);
20061        assert_eq!(entry.progress.active_tool_count, 9);
20062        assert_eq!(
20063            entry.progress.active_tool_labels.len(),
20064            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20065        );
20066        assert_eq!(entry.progress.subagent_count, 1);
20067        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20068        assert!(entry.progress.truncated);
20069        let encoded = serde_json::to_string(&entry).unwrap();
20070        for forbidden in [
20071            "PRIVATE_PROVIDER_SESSION",
20072            "PRIVATE_PROVIDER_PATH",
20073            "PRIVATE_MODEL",
20074            "PRIVATE_TOOL_INVENTORY",
20075            "PRIVATE_CURRENT_PROMPT",
20076            "PRIVATE_TOOL_INPUT_JSON",
20077            "PRIVATE_PROVIDER_REQUEST",
20078            "PRIVATE_INTERACTION_PROMPT",
20079            "PRIVATE_INTERACTION_AGENT",
20080            "PRIVATE_SUBAGENT_ID",
20081            "PRIVATE_SUBAGENT_TYPE",
20082            "PRIVATE_SUBAGENT_DESCRIPTION",
20083            "PRIVATE_PROVIDER_ERROR",
20084            "input_json",
20085            "current_prompt",
20086            "session_id",
20087        ] {
20088            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20089        }
20090
20091        let snapshot = NodeSnapshot {
20092            node_id: NodeId::new("node-progress-test").unwrap(),
20093            enabled_providers: vec![agent("claude")],
20094            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20095            workspaces: Vec::new(),
20096            session_records: Vec::new(),
20097            managed_worktrees: Vec::new(),
20098            launch_inventory: None,
20099            agent_progress: vec![entry],
20100        };
20101        let mut legacy_reply = ResponseEnvelope {
20102            request_id: 1,
20103            result: Ok(NodeResponse::Snapshot {
20104                event_sequence: 0,
20105                controller: None,
20106                snapshot: snapshot.clone(),
20107            }),
20108        };
20109        project_response_without_agent_progress(&mut legacy_reply);
20110        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20111            panic!("agent progress projection changed response kind");
20112        };
20113        assert!(legacy.agent_progress.is_empty());
20114        assert_eq!(snapshot.agent_progress.len(), 1);
20115    }
20116
20117    fn terminal_address(generation: u64) -> SessionAddress {
20118        SessionAddress {
20119            workspace_id: WorkspaceId::new("primary").unwrap(),
20120            session: SessionKey {
20121                instance_id: AgentInstanceId(41),
20122                generation: SessionGeneration(generation),
20123            },
20124        }
20125    }
20126
20127    fn spawn_spec_fixture() -> SpawnSpec {
20128        SpawnSpec {
20129            target: crate::protocol::SpawnTarget {
20130                node_id: NodeId::new("node-terminal-test").unwrap(),
20131                workspace_id: WorkspaceId::new("primary").unwrap(),
20132                worktree_id: None,
20133            },
20134            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20135            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20136                .unwrap(),
20137            overrides: crate::protocol::SpawnOverrides::default(),
20138            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20139            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20140            required_capabilities: SpawnRequiredCapabilities::default(),
20141        }
20142    }
20143
20144    fn environment_profile_test_shared(
20145    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20146        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20147        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20148            "local-claude-r1",
20149        )
20150        .unwrap();
20151        let spawn_profiles = SpawnProfileRegistry::new([
20152            crate::protocol::SpawnProfileDefaults {
20153                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20154                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20155                provider: agent("claude"),
20156                mode: SessionMode::Pty,
20157                terminal_size: gate4agent_types::TerminalSize {
20158                    rows: 24,
20159                    columns: 80,
20160                },
20161                prompt: None,
20162                bundle_id: None,
20163                context_id: None,
20164                environment_profile_id: Some(profile_id.clone()),
20165            },
20166        ])
20167        .unwrap();
20168        let catalog = active_registry().unwrap();
20169        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20170        let control = runtime.native_launch_profile_control();
20171        let node_profile = NodeEnvironmentProfile::new(
20172            profile_id.clone(),
20173            profile_revision.clone(),
20174            agent("claude"),
20175            [NativeLaunchProfile::new(
20176                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20177                agent("claude"),
20178                TransportKind::Pty,
20179                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20180                Arc::new(EmptyEnvironmentResolver),
20181            )
20182            .unwrap()],
20183        )
20184        .unwrap();
20185        let (binding, native_profiles, _materialization) = node_profile.into_parts();
20186        for native_profile in native_profiles {
20187            runtime.upsert_native_launch_profile(native_profile).unwrap();
20188        }
20189        let workspace = WorkspaceConfig::new(
20190            WorkspaceId::new("primary").unwrap(),
20191            std::env::current_dir().unwrap(),
20192        )
20193        .unwrap();
20194        let shared = NodeShared::new_with_incarnation(
20195            handle,
20196            "fixture-token".to_owned(),
20197            NodeId::new("node-terminal-test").unwrap(),
20198            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20199            vec![workspace],
20200            vec![agent("claude")],
20201            ProviderRuntimeStatuses::default(),
20202            None,
20203            Vec::new(),
20204            Vec::new(),
20205            spawn_profiles,
20206            Some(control),
20207            None,
20208            None,
20209            None,
20210            Vec::new(),
20211            Vec::new(),
20212            Vec::new(),
20213            Vec::new(),
20214            None,
20215            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20216        );
20217        shared
20218            .environment_profiles
20219            .write()
20220            .unwrap_or_else(|poisoned| poisoned.into_inner())
20221            .insert(binding.id.clone(), binding);
20222        (
20223            shared,
20224            runtime,
20225            ResolvedEnvironmentProfileReceipt {
20226                profile_id,
20227                profile_revision,
20228                network_allowlist: None,
20229                browser_profile_id: None,
20230            },
20231        )
20232    }
20233
20234    fn materialization_test_shared(
20235        deny_secret: bool,
20236    ) -> (
20237        NodeShared,
20238        NativeRuntime,
20239        ResolvedEnvironmentProfileReceipt,
20240        PathBuf,
20241        Arc<AtomicBool>,
20242    ) {
20243        let workspace = WorkspaceConfig::new(
20244            WorkspaceId::new("primary").unwrap(),
20245            std::env::current_dir().unwrap(),
20246        )
20247        .unwrap();
20248        materialization_test_shared_with_workspace(deny_secret, workspace)
20249    }
20250
20251    fn materialization_test_shared_with_workspace(
20252        deny_secret: bool,
20253        workspace: WorkspaceConfig,
20254    ) -> (
20255        NodeShared,
20256        NativeRuntime,
20257        ResolvedEnvironmentProfileReceipt,
20258        PathBuf,
20259        Arc<AtomicBool>,
20260    ) {
20261        let root = temporary_workspace_root(if deny_secret {
20262            "materialization-denied"
20263        } else {
20264            "materialization-ready"
20265        });
20266        std::fs::create_dir_all(&root).unwrap();
20267        let materialization_root = root.join("session-environments");
20268        let deny = Arc::new(AtomicBool::new(deny_secret));
20269        let resolver = Arc::new(FixtureSecretResolver {
20270            deny: Arc::clone(&deny),
20271        });
20272        let materializer = SessionEnvironmentMaterializer::new(
20273            materialization_root,
20274            resolver,
20275        )
20276        .unwrap();
20277        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20278        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20279            "materialized-claude-r1",
20280        )
20281        .unwrap();
20282        let materialization = NodeSessionMaterializationProfile::new(
20283            vec![NodeSessionEnvironmentMutation::SetSecret {
20284                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20285                reference: NodeSecretReference::new("fixture-token").unwrap(),
20286            }],
20287            vec![NodeSessionPathBinding::new(
20288                "GATE4AGENT_PROVIDER_HOME",
20289                NodeSessionPathClass::ProviderHome,
20290            )
20291            .unwrap()],
20292            vec![NodeSessionFile::secret(
20293                NodeSessionPathClass::Config,
20294                "auth/token",
20295                NodeSecretReference::new("fixture-token").unwrap(),
20296            )
20297            .unwrap()],
20298        )
20299        .unwrap();
20300        let catalog = active_registry().unwrap();
20301        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20302        let control = runtime.native_launch_profile_control();
20303        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20304            profile_id.clone(),
20305            profile_revision.clone(),
20306            agent("claude"),
20307            [NativeLaunchProfile::new(
20308                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20309                agent("claude"),
20310                TransportKind::Pty,
20311                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20312                Arc::new(EmptyEnvironmentResolver),
20313            )
20314            .unwrap()],
20315            Some(materialization),
20316        )
20317        .unwrap();
20318        let (binding, native_profiles, materialization) = node_profile.into_parts();
20319        for native_profile in native_profiles {
20320            runtime.upsert_native_launch_profile(native_profile).unwrap();
20321        }
20322        let shared = NodeShared::new_with_incarnation(
20323            handle,
20324            "fixture-token".to_owned(),
20325            NodeId::new("node-materialization-test").unwrap(),
20326            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20327            vec![workspace],
20328            vec![agent("claude")],
20329            ProviderRuntimeStatuses::default(),
20330            None,
20331            Vec::new(),
20332            Vec::new(),
20333            SpawnProfileRegistry::default(),
20334            Some(control),
20335            None,
20336            Some(materializer),
20337            None,
20338            Vec::new(),
20339            Vec::new(),
20340            Vec::new(),
20341            Vec::new(),
20342            None,
20343            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20344        );
20345        shared
20346            .environment_profiles
20347            .write()
20348            .unwrap_or_else(|poisoned| poisoned.into_inner())
20349            .insert(binding.id.clone(), binding);
20350        shared
20351            .environment_materialization_profiles
20352            .write()
20353            .unwrap_or_else(|poisoned| poisoned.into_inner())
20354            .insert(profile_id.clone(), materialization.unwrap());
20355        (
20356            shared,
20357            runtime,
20358            ResolvedEnvironmentProfileReceipt {
20359                profile_id,
20360                profile_revision,
20361                network_allowlist: None,
20362                browser_profile_id: None,
20363            },
20364            root,
20365            deny,
20366        )
20367    }
20368
20369    #[cfg(feature = "fixture")]
20370    #[derive(Clone, Copy)]
20371    enum CodexHomeProfile {
20372        Missing,
20373        WrongClass,
20374        Exact,
20375    }
20376
20377    #[cfg(feature = "fixture")]
20378    fn codex_bundle_test_shared(
20379        home_profile: CodexHomeProfile,
20380    ) -> (
20381        NodeShared,
20382        NativeRuntime,
20383        ResolvedEnvironmentProfileReceipt,
20384        NodeBundle,
20385        PathBuf,
20386    ) {
20387        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20388        const CLAUDE_MANIFEST: &[u8] =
20389            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20390        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20391        const DIGEST: &str =
20392            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20393
20394        let (shared, mut runtime, _claude_receipt, root, _deny) =
20395            materialization_test_shared(false);
20396        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20397            CodexHomeProfile::Missing => "codex-home-missing",
20398            CodexHomeProfile::WrongClass => "codex-home-wrong",
20399            CodexHomeProfile::Exact => "codex-home-exact",
20400        })
20401        .unwrap();
20402        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20403            "codex-home-r1",
20404        )
20405        .unwrap();
20406        let materialization = match home_profile {
20407            CodexHomeProfile::Missing => None,
20408            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20409                Some(NodeSessionMaterializationProfile::new(
20410                    Vec::new(),
20411                    vec![NodeSessionPathBinding::new(
20412                        "CODEX_HOME",
20413                        if matches!(home_profile, CodexHomeProfile::Exact) {
20414                            NodeSessionPathClass::ProviderHome
20415                        } else {
20416                            NodeSessionPathClass::Config
20417                        },
20418                    )
20419                    .unwrap()],
20420                    Vec::new(),
20421                )
20422                .unwrap())
20423            }
20424        };
20425        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20426            profile_id.clone(),
20427            profile_revision.clone(),
20428            agent("codex"),
20429            [NativeLaunchProfile::new(
20430                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20431                agent("codex"),
20432                TransportKind::Pty,
20433                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20434                Arc::new(EmptyEnvironmentResolver),
20435            )
20436            .unwrap()],
20437            materialization,
20438        )
20439        .unwrap();
20440        let (binding, native_profiles, materialization) = node_profile.into_parts();
20441        for native_profile in native_profiles {
20442            runtime.upsert_native_launch_profile(native_profile).unwrap();
20443        }
20444        shared
20445            .environment_profiles
20446            .write()
20447            .unwrap_or_else(|poisoned| poisoned.into_inner())
20448            .insert(binding.id.clone(), binding);
20449        if let Some(materialization) = materialization {
20450            shared
20451                .environment_materialization_profiles
20452                .write()
20453                .unwrap_or_else(|poisoned| poisoned.into_inner())
20454                .insert(profile_id.clone(), materialization);
20455        }
20456
20457        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20458        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20459        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20460        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20461        std::fs::write(
20462            bundle_source.join(".claude-plugin/plugin.json"),
20463            CLAUDE_MANIFEST,
20464        )
20465        .unwrap();
20466        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20467        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20468        let bundle = NodeBundle::new(
20469            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20470            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20471            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20472            &bundle_source,
20473        )
20474        .unwrap();
20475        *shared
20476            .bundle_catalog
20477            .write()
20478            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20479            BundleCatalog::new([bundle.clone()]).unwrap();
20480        (
20481            shared,
20482            runtime,
20483            ResolvedEnvironmentProfileReceipt {
20484                profile_id,
20485                profile_revision,
20486                network_allowlist: None,
20487                browser_profile_id: None,
20488            },
20489            bundle,
20490            root,
20491        )
20492    }
20493
20494    #[cfg(feature = "fixture")]
20495    #[test]
20496    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20497        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20498            let (shared, runtime, receipt, bundle, root) =
20499                codex_bundle_test_shared(home_profile);
20500            let error = shared
20501                .bundle_layout(
20502                    &agent("codex"),
20503                    SessionMode::Pty,
20504                    Some(&receipt),
20505                    &bundle,
20506                )
20507                .unwrap_err();
20508            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20509            assert!(shared
20510                .materializations
20511                .lock()
20512                .unwrap_or_else(|poisoned| poisoned.into_inner())
20513                .is_empty());
20514            let materialization_root = root.join("session-environments");
20515            assert!(!std::fs::read_dir(&materialization_root)
20516                .unwrap()
20517                .filter_map(Result::ok)
20518                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20519            drop(shared);
20520            drop(runtime);
20521            let _ = std::fs::remove_dir_all(root);
20522        }
20523    }
20524
20525    #[cfg(feature = "fixture")]
20526    #[test]
20527    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20528        let (shared, runtime, receipt, bundle, root) =
20529            codex_bundle_test_shared(CodexHomeProfile::Exact);
20530        let address = SessionAddress {
20531            workspace_id: WorkspaceId::new("primary").unwrap(),
20532            session: SessionKey {
20533                instance_id: AgentInstanceId(310),
20534                generation: SessionGeneration(1),
20535            },
20536        };
20537        let (overlay, guard) = shared
20538            .prepare_session_materialization(
20539                &address,
20540                &agent("codex"),
20541                SessionMode::Pty,
20542                Some(&receipt),
20543                Some(&bundle.receipt()),
20544                None,
20545                None,
20546            )
20547            .unwrap()
20548            .unwrap();
20549        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20550        let ownership = shared
20551            .materializations
20552            .lock()
20553            .unwrap_or_else(|poisoned| poisoned.into_inner())
20554            .get(guard.id().unwrap())
20555            .unwrap()
20556            .clone();
20557        let (profile, _, _) = shared
20558            .materialization_profile(
20559                &agent("codex"),
20560                SessionMode::Pty,
20561                Some(&receipt),
20562                Some(&bundle.receipt()),
20563                None,
20564            )
20565            .unwrap()
20566            .unwrap();
20567        let environment = shared
20568            .session_environment_materializer
20569            .as_ref()
20570            .unwrap()
20571            .resolve_environment(&ownership, &profile)
20572            .unwrap();
20573        assert!(environment.iter().any(|mutation| {
20574            mutation.key == OsString::from("CODEX_HOME")
20575                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20576        }));
20577        assert_eq!(
20578            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20579                .unwrap(),
20580            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20581        );
20582        assert!(std::fs::read_dir(ownership.bundle_root())
20583            .unwrap()
20584            .next()
20585            .is_none());
20586        drop(guard);
20587        drop(shared);
20588        drop(runtime);
20589        let _ = std::fs::remove_dir_all(root);
20590    }
20591
20592    #[cfg(feature = "fixture")]
20593    #[test]
20594    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20595        let (shared, runtime, receipt, bundle, root) =
20596            codex_bundle_test_shared(CodexHomeProfile::Exact);
20597        let bundle_receipt = bundle.receipt();
20598        let address = SessionAddress {
20599            workspace_id: WorkspaceId::new("primary").unwrap(),
20600            session: SessionKey {
20601                instance_id: AgentInstanceId(311),
20602                generation: SessionGeneration(1),
20603            },
20604        };
20605        let (_overlay, guard) = shared
20606            .prepare_session_materialization(
20607                &address,
20608                &agent("codex"),
20609                SessionMode::Pty,
20610                Some(&receipt),
20611                Some(&bundle_receipt),
20612                None,
20613                None,
20614            )
20615            .unwrap()
20616            .unwrap();
20617        let materialization_id = guard.id().unwrap().clone();
20618        let skill_path = shared
20619            .materializations
20620            .lock()
20621            .unwrap_or_else(|poisoned| poisoned.into_inner())
20622            .get(&materialization_id)
20623            .unwrap()
20624            .provider_home()
20625            .join("skills/review-code/SKILL.md");
20626        let record_id = shared
20627            .bind_spawn_session_with_materialization(
20628                &address,
20629                agent("codex"),
20630                SessionMode::Pty,
20631                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20632                SpawnRecordPolicy::ProviderIdentityOnly,
20633                Some(receipt.clone()),
20634                Some(bundle_receipt.clone()),
20635                None,
20636                Some(materialization_id.clone()),
20637            )
20638            .unwrap()
20639            .unwrap();
20640        guard.retain();
20641        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20642
20643        let error = match shared.resolve_record_materialization(
20644                &record_id,
20645                &agent("codex"),
20646                SessionMode::Pty,
20647                Some(&receipt),
20648                Some(&bundle_receipt),
20649                None,
20650            ) {
20651                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20652                Err(error) => error,
20653            };
20654        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20655        assert_eq!(
20656            shared
20657                .materializations
20658                .lock()
20659                .unwrap_or_else(|poisoned| poisoned.into_inner())
20660                .get(&materialization_id)
20661                .unwrap()
20662                .state(),
20663            MaterializationState::RecoveryRequired,
20664        );
20665        drop(shared);
20666        drop(runtime);
20667        let _ = std::fs::remove_dir_all(root);
20668    }
20669
20670    fn bind_record_owned_materialization_fixture(
20671        shared: &NodeShared,
20672        receipt: &ResolvedEnvironmentProfileReceipt,
20673        instance_id: u64,
20674    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20675        let address = SessionAddress {
20676            workspace_id: WorkspaceId::new("primary").unwrap(),
20677            session: SessionKey {
20678                instance_id: AgentInstanceId(instance_id),
20679                generation: SessionGeneration(1),
20680            },
20681        };
20682        let (overlay, materialization_guard) = shared
20683            .prepare_session_materialization(
20684                &address,
20685                &agent("claude"),
20686                SessionMode::Pty,
20687                Some(receipt),
20688                None,
20689                None,
20690                None,
20691            )
20692            .unwrap()
20693            .unwrap();
20694        let materialization_id = materialization_guard.id().unwrap().clone();
20695        let materialization_root = shared
20696            .materializations
20697            .lock()
20698            .unwrap_or_else(|poisoned| poisoned.into_inner())
20699            .get(&materialization_id)
20700            .unwrap()
20701            .root()
20702            .to_path_buf();
20703        let selection = shared
20704            .select_environment_profile(
20705                address.session.instance_id,
20706                &agent("claude"),
20707                SessionMode::Pty,
20708                Some(receipt),
20709            )
20710            .unwrap()
20711            .unwrap();
20712        assert!(shared
20713            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20714            .unwrap()
20715            .is_none());
20716        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20717        let record_id = shared
20718            .bind_spawn_session_with_materialization(
20719                &address,
20720                agent("claude"),
20721                SessionMode::Pty,
20722                policy,
20723                SpawnRecordPolicy::ProviderIdentityOnly,
20724                Some(receipt.clone()),
20725                None,
20726                None,
20727                Some(materialization_id.clone()),
20728            )
20729            .unwrap()
20730            .unwrap();
20731        selection.retain();
20732        materialization_guard.retain();
20733        (address, record_id, materialization_id, materialization_root)
20734    }
20735
20736    #[test]
20737    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20738        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20739        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20740            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20741        }));
20742        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20743        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20744        let mut spec = spawn_spec_fixture();
20745        spec.expected_profile_revision =
20746            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20747        assert!(matches!(
20748            &spec.overrides.environment_profile_id,
20749            crate::protocol::SpawnOverride::Inherit,
20750        ));
20751        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20752            .requires_child_environment_profile_capability());
20753        assert!(request_requires_child_environment_profile(
20754            &shared,
20755            &NodeRequest::SpawnSpec { spec: spec.clone() },
20756        ));
20757
20758        let address = terminal_address(1);
20759        shared.bind_session_with_policy(
20760            &address,
20761            ProviderRuntimePolicy::raw_pty(),
20762            Some(environment_profile.clone()),
20763        );
20764        assert!(request_requires_child_environment_profile(
20765            &shared,
20766            &NodeRequest::Input {
20767                session: address.clone(),
20768                text: "x".to_owned(),
20769            },
20770        ));
20771
20772        let mut record = record("claude", "profile-record");
20773        record.active_session = Some(address.clone());
20774        record.environment_profile = Some(environment_profile.clone());
20775        let record_id = record.record_id.clone();
20776        shared.insert_record(record.clone()).unwrap();
20777        assert!(request_requires_child_environment_profile(
20778            &shared,
20779            &NodeRequest::ResumeSessionRecord {
20780                record_id,
20781                terminal_size: gate4agent_types::TerminalSize {
20782                    rows: 24,
20783                    columns: 80,
20784                },
20785                initial_prompt: None,
20786            },
20787        ));
20788
20789        let snapshot = snapshot_for_wire(
20790            &shared, false, true, true, true, false, false, false, false, true,
20791        );
20792        assert_eq!(snapshot.session_records.len(), 1);
20793        assert!(snapshot.session_records[0].environment_profile.is_none());
20794        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20795            sequence: 1,
20796            event: NodeEvent::SessionRecordUpserted { record },
20797        });
20798        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20799            panic!("record projection changed the event kind");
20800        };
20801        assert!(record.environment_profile.is_none());
20802
20803        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20804        let mut reply = ResponseEnvelope {
20805            request_id: 1,
20806            result: Ok(NodeResponse::SpawnSpecAccepted {
20807                receipt: resolved.receipt_with_environment(
20808                    shared.incarnation_id,
20809                    address,
20810                    Some(environment_profile),
20811                ),
20812            }),
20813        };
20814        project_response_without_child_environment_profile(&mut reply);
20815        assert_eq!(
20816            reply.result.unwrap_err().code,
20817            NodeFailureCode::UnsupportedCapability,
20818        );
20819    }
20820
20821    #[test]
20822    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20823        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20824        let snapshot = shared.snapshot();
20825        let profiles = snapshot
20826            .launch_inventory
20827            .as_ref()
20828            .and_then(|inventory| inventory.spawn_profiles.as_ref())
20829            .unwrap();
20830        assert_eq!(profiles.len(), 1);
20831        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20832        assert!(snapshot.requires_child_environment_profile_capability());
20833
20834        let mut legacy = snapshot.clone();
20835        clear_snapshot_child_environment_profiles(&mut legacy);
20836        assert!(legacy
20837            .launch_inventory
20838            .as_ref()
20839            .unwrap()
20840            .spawn_profiles
20841            .as_ref()
20842            .unwrap()[0]
20843            .environment_profile
20844            .is_none());
20845        assert!(!legacy.requires_child_environment_profile_capability());
20846
20847        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20848        let bindings = shared
20849            .environment_profiles
20850            .read()
20851            .unwrap_or_else(|poisoned| poisoned.into_inner())
20852            .clone();
20853        let mut provider_mismatch = bindings.clone();
20854        provider_mismatch
20855            .values_mut()
20856            .next()
20857            .unwrap()
20858            .provider = agent("codex");
20859        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20860        let mut mode_mismatch = profile;
20861        mode_mismatch.mode = SessionMode::Inline;
20862        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20863
20864        shared
20865            .environment_profiles
20866            .write()
20867            .unwrap_or_else(|poisoned| poisoned.into_inner())
20868            .clear();
20869        assert!(shared
20870            .snapshot()
20871            .launch_inventory
20872            .unwrap()
20873            .spawn_profiles
20874            .unwrap()
20875            .is_empty());
20876    }
20877
20878    #[test]
20879    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20880        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20881            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20882        }));
20883        let shared = terminal_test_shared();
20884        let bundle = ResolvedBundleReceipt {
20885            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20886            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20887            digest: crate::protocol::SpawnBundleDigest::new(format!(
20888                "sha256:{}",
20889                "0".repeat(64),
20890            ))
20891            .unwrap(),
20892        };
20893        let mut bundled = record("claude", "bundle-record");
20894        bundled.bundle = Some(bundle.clone());
20895        let record_id = bundled.record_id.clone();
20896        shared.insert_record(bundled.clone()).unwrap();
20897        assert!(request_requires_session_bundle(
20898            &shared,
20899            &NodeRequest::ForgetSessionRecord {
20900                record_id: record_id.clone(),
20901            },
20902        ));
20903
20904        let snapshot = snapshot_for_wire(
20905            &shared, false, true, true, true, true, false, false, false, true,
20906        );
20907        assert!(snapshot.session_records[0].bundle.is_none());
20908        let launch = snapshot.launch_inventory.as_ref().unwrap();
20909        assert!(launch.spawn_profiles.is_some());
20910        assert!(launch.bundles.is_none());
20911        let event = project_event_without_session_bundle(NodeEventEnvelope {
20912            sequence: 1,
20913            event: NodeEvent::SessionRecordUpserted { record: bundled },
20914        });
20915        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20916            panic!("record projection changed the event kind");
20917        };
20918        assert!(record.bundle.is_none());
20919
20920        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20921        let mut reply = ResponseEnvelope {
20922            request_id: 1,
20923            result: Ok(NodeResponse::SpawnSpecAccepted {
20924                receipt: resolved.receipt_with_materialization(
20925                    shared.incarnation_id,
20926                    terminal_address(17),
20927                    None,
20928                    Some(bundle),
20929                    None,
20930                ),
20931            }),
20932        };
20933        project_response_without_session_bundle(&mut reply);
20934        assert_eq!(
20935            reply.result.unwrap_err().code,
20936            NodeFailureCode::UnsupportedCapability,
20937        );
20938    }
20939
20940    #[test]
20941    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20942        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20943            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20944        }));
20945        let context_id = SpawnContextId::new("context-review").unwrap();
20946        let context = ResolvedContextPackReceipt {
20947            id: context_id.clone(),
20948            digest: crate::protocol::SpawnContextDigest::new(format!(
20949                "sha256:{}",
20950                "c".repeat(64),
20951            ))
20952            .unwrap(),
20953            lineage: ContextPackLineageReceipt {
20954                source_node_id: NodeId::new("node-source").unwrap(),
20955                source_session: terminal_address(1),
20956                source_provider: agent("kimi"),
20957            },
20958            source_message_count: 2,
20959            retained_message_count: 2,
20960            byte_len: 256,
20961            truncated: false,
20962        };
20963        assert!(context.is_valid());
20964        let mut bound_record = record("codex", "context-record");
20965        bound_record.context_id = Some(context_id);
20966        bound_record.context = Some(context.clone());
20967        let session = gate4agent_types::SessionSnapshot {
20968            instance_id: AgentInstanceId(91),
20969            agent_id: agent("kimi"),
20970            transport: TransportKind::Pty,
20971            generation: SessionGeneration(3),
20972            status: SessionStatus::Running,
20973            pending_operation: None,
20974            pending_input: None,
20975            process_id: Some(9001),
20976            terminal_size: None,
20977            terminal_frame: None,
20978            terminal_stale: None,
20979            session_options: None,
20980            capabilities: gate4agent_types::CapabilitySnapshot::default(),
20981            history: gate4agent_types::HistorySnapshot {
20982                pending: None,
20983                candidates: vec![HistoryCandidateSummary {
20984                    id: "candidate-opaque".to_owned(),
20985                    session_id_hint: "session-hint".to_owned(),
20986                    modified_at_unix_ms: Some(1),
20987                }],
20988                loaded_candidate_id: Some("candidate-opaque".to_owned()),
20989                loaded: Some(HistorySessionRecord {
20990                    session_id: "private-vendor-session".to_owned(),
20991                    title: Some("private title".to_owned()),
20992                    cwd: Some(r"C:\private\history-root".to_owned()),
20993                    model: Some("private-model".to_owned()),
20994                    message_count: 2,
20995                    completed_turn_count: None,
20996                    total_tokens: 19,
20997                    messages: vec![
20998                        gate4agent_types::HistoryMessageRecord {
20999                            role: gate4agent_types::HistoryMessageRole::User,
21000                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21001                        },
21002                        gate4agent_types::HistoryMessageRecord {
21003                            role: gate4agent_types::HistoryMessageRole::Assistant,
21004                            text: "private answer".to_owned(),
21005                        },
21006                    ],
21007                }),
21008                last_error: None,
21009            },
21010            resume: gate4agent_types::ResumeSnapshot::default(),
21011            foreground: gate4agent_types::ForegroundSnapshot::default(),
21012            provider: gate4agent_types::ProviderSnapshot::default(),
21013            screen_state: gate4agent_types::PtyScreenState::default(),
21014        };
21015        let original = NodeSnapshot {
21016            node_id: NodeId::new("node-terminal-test").unwrap(),
21017            enabled_providers: vec![agent("kimi"), agent("codex")],
21018            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21019            workspaces: vec![WorkspaceSnapshot {
21020                workspace_id: WorkspaceId::new("primary").unwrap(),
21021                canonical_root: opaque_windows_path(
21022                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21023                ),
21024                sessions: vec![session],
21025                worktree_service_mode: None,
21026                managed_worktree_profiles: None,
21027            }],
21028            session_records: vec![bound_record],
21029            managed_worktrees: Vec::new(),
21030            launch_inventory: None,
21031            agent_progress: Vec::new(),
21032        };
21033
21034        let mut negotiated = original.clone();
21035        project_snapshot_history_for_wire(&mut negotiated, true);
21036        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21037        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21038        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21039        assert!(!serde_json::to_string(&negotiated)
21040            .unwrap()
21041            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21042
21043        let mut legacy = original;
21044        project_snapshot_history_for_wire(&mut legacy, false);
21045        clear_snapshot_context_packs(&mut legacy);
21046        assert_eq!(
21047            legacy.workspaces[0].sessions[0].history,
21048            gate4agent_types::HistorySnapshot::default(),
21049        );
21050        assert!(legacy.session_records[0].context_id.is_none());
21051        assert!(legacy.session_records[0].context.is_none());
21052        let encoded = serde_json::to_string(&legacy).unwrap();
21053        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21054        assert!(!encoded.contains("context-review"));
21055
21056        let mut reply = ResponseEnvelope {
21057            request_id: 7,
21058            result: Ok(NodeResponse::ContextPackExported { context }),
21059        };
21060        project_response_without_context_pack(&mut reply);
21061        assert_eq!(
21062            reply.result.unwrap_err().code,
21063            NodeFailureCode::UnsupportedCapability,
21064        );
21065    }
21066
21067    #[test]
21068    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21069        let shared = terminal_test_shared();
21070        let history = HistorySessionRecord {
21071            session_id: "fixture-history".to_owned(),
21072            title: None,
21073            cwd: None,
21074            model: None,
21075            message_count: 1,
21076            completed_turn_count: None,
21077            total_tokens: 0,
21078            messages: vec![gate4agent_types::HistoryMessageRecord {
21079                role: gate4agent_types::HistoryMessageRole::User,
21080                text: "bounded handoff".to_owned(),
21081            }],
21082        };
21083        let pack = NodeContextPack::export(
21084            ContextPackLineageReceipt {
21085                source_node_id: shared.node_id.clone(),
21086                source_session: terminal_address(1),
21087                source_provider: agent("claude"),
21088            },
21089            &history,
21090        )
21091        .unwrap();
21092        let context = shared
21093            .context_catalog
21094            .write()
21095            .unwrap_or_else(|poisoned| poisoned.into_inner())
21096            .insert(pack)
21097            .unwrap();
21098        let mut retained = record("codex", "context-retained-record");
21099        retained.context_id = Some(context.id.clone());
21100        retained.context = Some(context.clone());
21101        let record_id = retained.record_id.clone();
21102        shared.insert_record(retained).unwrap();
21103        assert_eq!(
21104            shared.forget_context_pack(&context.id).unwrap_err().code,
21105            NodeFailureCode::ContextPackBusy,
21106        );
21107        assert!(shared
21108            .context_catalog
21109            .read()
21110            .unwrap_or_else(|poisoned| poisoned.into_inner())
21111            .get(&context.id)
21112            .is_some());
21113        shared
21114            .session_records
21115            .lock()
21116            .unwrap_or_else(|poisoned| poisoned.into_inner())
21117            .records
21118            .remove(&record_id);
21119
21120        let mut spec = spawn_spec_fixture();
21121        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21122            value: context.id.clone(),
21123        };
21124        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21125        let receipt = resolved.receipt_with_materialization(
21126            shared.incarnation_id,
21127            terminal_address(2),
21128            None,
21129            None,
21130            Some(context.clone()),
21131        );
21132        assert!(receipt.context_binding_is_valid());
21133        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21134
21135        shared.forget_context_pack(&context.id).unwrap();
21136        assert!(shared
21137            .context_catalog
21138            .read()
21139            .unwrap_or_else(|poisoned| poisoned.into_inner())
21140            .get(&context.id)
21141            .is_none());
21142        assert!(shared
21143            .replay_spawn_spec(&spec, Instant::now())
21144            .unwrap()
21145            .is_none());
21146    }
21147
21148    #[test]
21149    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21150        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21151        let address = terminal_address(1);
21152        let selection = shared
21153            .select_environment_profile(
21154                address.session.instance_id,
21155                &agent("claude"),
21156                SessionMode::Pty,
21157                Some(&environment_profile),
21158            )
21159            .unwrap()
21160            .unwrap();
21161        shared.bind_session_with_policy(
21162            &address,
21163            ProviderRuntimePolicy::raw_pty(),
21164            Some(environment_profile.clone()),
21165        );
21166        selection.retain();
21167        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21168        assert!(matches!(
21169            runtime.remove_native_launch_profile(&native_id),
21170            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21171        ));
21172        assert!(shared.remove_binding(&address).is_some());
21173        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21174
21175        let mismatched = ResolvedEnvironmentProfileReceipt {
21176            profile_id: environment_profile.profile_id,
21177            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21178                "local-claude-r2",
21179            )
21180            .unwrap(),
21181            network_allowlist: None,
21182            browser_profile_id: None,
21183        };
21184        let error = match shared.select_environment_profile(
21185                AgentInstanceId(42),
21186                &agent("claude"),
21187                SessionMode::Pty,
21188                Some(&mismatched),
21189            ) {
21190            Ok(_) => panic!("mismatched environment profile revision was selected"),
21191            Err(error) => error,
21192        };
21193        assert_eq!(
21194            error.code,
21195            NodeFailureCode::EnvironmentProfileBindingMismatch,
21196        );
21197        assert!(!shared
21198            .session_bindings
21199            .lock()
21200            .unwrap_or_else(|poisoned| poisoned.into_inner())
21201            .contains_key(&AgentInstanceId(42)));
21202    }
21203
21204    #[test]
21205    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21206        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21207        let address = terminal_address(1);
21208        let selection = shared
21209            .select_environment_profile(
21210                address.session.instance_id,
21211                &agent("claude"),
21212                SessionMode::Pty,
21213                Some(&environment_profile),
21214            )
21215            .unwrap()
21216            .unwrap();
21217        let overlay = NativeInstanceLaunchOverlay::new(
21218            agent("claude"),
21219            TransportKind::Pty,
21220            vec![EnvMutation {
21221                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21222                value: Some(OsString::from("fixture-secret")),
21223            }],
21224            vec![
21225                OsString::from("--plugin-dir"),
21226                std::env::current_dir().unwrap().into_os_string(),
21227            ],
21228        )
21229        .unwrap();
21230        let instance_overlay = shared
21231            .install_prepared_launch_overlay(
21232                address.session.instance_id,
21233                PreparedNativeLaunchOverlay::Instance(overlay),
21234            )
21235            .unwrap()
21236            .unwrap();
21237        let bundle = ResolvedBundleReceipt {
21238            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21239            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21240            digest: crate::protocol::SpawnBundleDigest::new(format!(
21241                "sha256:{}",
21242                "0".repeat(64),
21243            ))
21244            .unwrap(),
21245        };
21246        shared.bind_session_with_materialization(
21247            &address,
21248            ProviderRuntimePolicy::raw_pty(),
21249            Some(environment_profile),
21250            Some(bundle),
21251            None,
21252            None,
21253        );
21254        selection.retain();
21255        instance_overlay.retain();
21256
21257        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21258        assert!(matches!(
21259            runtime.remove_native_launch_profile(&native_id),
21260            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21261        ));
21262        assert!(shared.remove_binding(&address).is_some());
21263        assert!(!shared
21264            .native_launch_profile_control
21265            .as_ref()
21266            .unwrap()
21267            .clear_native_instance_launch_overlay(address.session.instance_id));
21268        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21269    }
21270
21271    #[test]
21272    fn session_environment_materialization_failure_is_pre_child_and_durable() {
21273        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21274        let address = SessionAddress {
21275            workspace_id: WorkspaceId::new("primary").unwrap(),
21276            session: SessionKey {
21277                instance_id: AgentInstanceId(201),
21278                generation: SessionGeneration(1),
21279            },
21280        };
21281        let error = match shared.prepare_session_materialization(
21282            &address,
21283            &agent("claude"),
21284            SessionMode::Pty,
21285            Some(&receipt),
21286            None,
21287            None,
21288            None,
21289        ) {
21290            Ok(_) => panic!("denied local reference unexpectedly materialized"),
21291            Err(error) => error,
21292        };
21293        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21294        assert!(shared.handle.snapshot().sessions.is_empty());
21295        assert!(shared
21296            .session_bindings
21297            .lock()
21298            .unwrap_or_else(|poisoned| poisoned.into_inner())
21299            .is_empty());
21300        assert!(shared
21301            .materializations
21302            .lock()
21303            .unwrap_or_else(|poisoned| poisoned.into_inner())
21304            .is_empty());
21305        drop(shared);
21306        drop(runtime);
21307        std::fs::remove_dir_all(root).unwrap();
21308    }
21309
21310    #[test]
21311    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21312        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21313        let address = SessionAddress {
21314            workspace_id: WorkspaceId::new("primary").unwrap(),
21315            session: SessionKey {
21316                instance_id: AgentInstanceId(202),
21317                generation: SessionGeneration(1),
21318            },
21319        };
21320        let (overlay, materialization_guard) = shared
21321            .prepare_session_materialization(
21322                &address,
21323                &agent("claude"),
21324                SessionMode::Pty,
21325                Some(&receipt),
21326                None,
21327                None,
21328                None,
21329            )
21330            .unwrap()
21331            .unwrap();
21332        let materialization_id = materialization_guard.id().unwrap().clone();
21333        let materialization_root = shared
21334            .materializations
21335            .lock()
21336            .unwrap_or_else(|poisoned| poisoned.into_inner())
21337            .get(&materialization_id)
21338            .unwrap()
21339            .root()
21340            .to_path_buf();
21341        let selection = shared
21342            .select_environment_profile(
21343                address.session.instance_id,
21344                &agent("claude"),
21345                SessionMode::Pty,
21346                Some(&receipt),
21347            )
21348            .unwrap()
21349            .unwrap();
21350        assert!(shared
21351            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21352            .unwrap()
21353            .is_none());
21354        shared.bind_session_with_materialization(
21355            &address,
21356            ProviderRuntimePolicy::raw_pty(),
21357            Some(receipt),
21358            None,
21359            None,
21360            Some(materialization_id),
21361        );
21362        selection.retain();
21363        materialization_guard.retain();
21364
21365        assert!(materialization_root.is_dir());
21366        assert!(shared
21367            .session_bindings
21368            .lock()
21369            .unwrap_or_else(|poisoned| poisoned.into_inner())
21370            .contains_key(&address.session.instance_id));
21371
21372        let removed = shared.remove_binding(&address).unwrap();
21373        shared
21374            .cleanup_session_owned_materialization(&address, &removed)
21375            .unwrap();
21376        assert!(!materialization_root.exists());
21377        assert!(shared
21378            .materializations
21379            .lock()
21380            .unwrap_or_else(|poisoned| poisoned.into_inner())
21381            .is_empty());
21382        drop(shared);
21383        drop(runtime);
21384        std::fs::remove_dir_all(root).unwrap();
21385    }
21386
21387    #[tokio::test]
21388    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21389        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21390        let address = SessionAddress {
21391            workspace_id: WorkspaceId::new("primary").unwrap(),
21392            session: SessionKey {
21393                instance_id: AgentInstanceId(203),
21394                generation: SessionGeneration(1),
21395            },
21396        };
21397        let (overlay, materialization_guard) = shared
21398            .prepare_session_materialization(
21399                &address,
21400                &agent("claude"),
21401                SessionMode::Pty,
21402                Some(&receipt),
21403                None,
21404                None,
21405                None,
21406            )
21407            .unwrap()
21408            .unwrap();
21409        let materialization_id = materialization_guard.id().unwrap().clone();
21410        let materialization_root = shared
21411            .materializations
21412            .lock()
21413            .unwrap_or_else(|poisoned| poisoned.into_inner())
21414            .get(&materialization_id)
21415            .unwrap()
21416            .root()
21417            .to_path_buf();
21418        let selection = shared
21419            .select_environment_profile(
21420                address.session.instance_id,
21421                &agent("claude"),
21422                SessionMode::Pty,
21423                Some(&receipt),
21424            )
21425            .unwrap()
21426            .unwrap();
21427        assert!(shared
21428            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21429            .unwrap()
21430            .is_none());
21431        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21432        let record_id = shared
21433            .bind_spawn_session_with_materialization(
21434                &address,
21435                agent("claude"),
21436                SessionMode::Pty,
21437                policy,
21438                SpawnRecordPolicy::ProviderIdentityOnly,
21439                Some(receipt.clone()),
21440                None,
21441                None,
21442                Some(materialization_id.clone()),
21443            )
21444            .unwrap()
21445            .unwrap();
21446        selection.retain();
21447        materialization_guard.retain();
21448        assert_eq!(
21449            shared
21450                .materializations
21451                .lock()
21452                .unwrap_or_else(|poisoned| poisoned.into_inner())
21453                .get(&materialization_id)
21454                .unwrap()
21455                .owner(),
21456            &MaterializationOwner::Record {
21457                record_id: record_id.clone(),
21458            },
21459        );
21460
21461        let removed = shared.remove_binding(&address).unwrap();
21462        shared
21463            .cleanup_session_owned_materialization(&address, &removed)
21464            .unwrap();
21465        assert!(materialization_root.is_dir());
21466        assert!(shared
21467            .resolve_record_materialization(
21468                &record_id,
21469                &agent("claude"),
21470                SessionMode::Pty,
21471                Some(&receipt),
21472                None,
21473                None,
21474            )
21475            .unwrap()
21476            .is_some());
21477        {
21478            let mut records = shared
21479                .session_records
21480                .lock()
21481                .unwrap_or_else(|poisoned| poisoned.into_inner());
21482            let record = records.records.get_mut(&record_id).unwrap();
21483            record.active_session = None;
21484            record.state = ManagedSessionState::Unavailable;
21485        }
21486        shared.forget_session_record(&record_id).await.unwrap();
21487        assert!(!materialization_root.exists());
21488        assert!(shared
21489            .materializations
21490            .lock()
21491            .unwrap_or_else(|poisoned| poisoned.into_inner())
21492            .is_empty());
21493        drop(shared);
21494        drop(runtime);
21495        std::fs::remove_dir_all(root).unwrap();
21496    }
21497
21498    #[test]
21499    fn record_materialization_revalidation_failure_persists_recovery_required() {
21500        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21501        let state_path = root.join("node-state-v6.json");
21502        shared.state_path = Some(state_path.clone());
21503        let (_address, _record_id, materialization_id, materialization_root) =
21504            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21505        std::fs::write(
21506            materialization_root.join(".gate4agent-materialization-owner"),
21507            b"tampered",
21508        )
21509        .unwrap();
21510
21511        let error = match shared.resolve_record_materialization(
21512            &_record_id,
21513            &agent("claude"),
21514            SessionMode::Pty,
21515            Some(&receipt),
21516            None,
21517            None,
21518        ) {
21519            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21520            Err(error) => error,
21521        };
21522        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21523        assert_eq!(
21524            shared
21525                .materializations
21526                .lock()
21527                .unwrap_or_else(|poisoned| poisoned.into_inner())
21528                .get(&materialization_id)
21529                .unwrap()
21530                .state(),
21531            MaterializationState::RecoveryRequired,
21532        );
21533        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21534        assert_eq!(loaded.materializations.len(), 1);
21535        assert_eq!(
21536            loaded.materializations[0].state(),
21537            MaterializationState::RecoveryRequired,
21538        );
21539        drop(shared);
21540        drop(runtime);
21541        std::fs::remove_dir_all(root).unwrap();
21542    }
21543
21544    #[test]
21545    fn transient_record_materialization_resolution_failure_remains_ready() {
21546        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21547        let (_address, record_id, materialization_id, _materialization_root) =
21548            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21549        deny.store(true, Ordering::Release);
21550
21551        let error = match shared.resolve_record_materialization(
21552            &record_id,
21553            &agent("claude"),
21554            SessionMode::Pty,
21555            Some(&receipt),
21556            None,
21557            None,
21558        ) {
21559            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21560            Err(error) => error,
21561        };
21562        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21563        assert_eq!(
21564            shared
21565                .materializations
21566                .lock()
21567                .unwrap_or_else(|poisoned| poisoned.into_inner())
21568                .get(&materialization_id)
21569                .unwrap()
21570                .state(),
21571            MaterializationState::Ready,
21572        );
21573        drop(shared);
21574        drop(runtime);
21575        std::fs::remove_dir_all(root).unwrap();
21576    }
21577
21578    #[test]
21579    fn startup_missing_record_materialization_is_not_resumable() {
21580        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21581        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21582        shared
21583            .insert_record(ManagedSessionRecord {
21584                record_id: record_id.clone(),
21585                display_name: "missing materialization".to_owned(),
21586                provider: agent("claude"),
21587                mode: SessionMode::Pty,
21588                state: ManagedSessionState::Dormant,
21589                workspace_id: WorkspaceId::new("primary").unwrap(),
21590                canonical_root: opaque_windows_path(
21591                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21592                ),
21593                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21594                    key: gate4agent_types::ProviderSessionKey::SessionId,
21595                    id: "provider-session-missing-materialization".to_owned(),
21596                    transcript_path: None,
21597                }),
21598                active_session: None,
21599                environment_profile: Some(receipt),
21600                bundle: None,
21601                context_id: None,
21602                context: None,
21603                exported_context: None,
21604                task_binding: None,
21605                created_at_unix_ms: 1,
21606                updated_at_unix_ms: 1,
21607                last_error: None,
21608            })
21609            .unwrap();
21610
21611        shared.reconcile_materializations();
21612        let record = shared.record(&record_id).unwrap();
21613        assert_eq!(record.state, ManagedSessionState::Unavailable);
21614        assert!(record.provider_session.is_none());
21615        assert_eq!(
21616            record.last_error.as_deref(),
21617            Some("environment-profile-unavailable"),
21618        );
21619        drop(shared);
21620        drop(runtime);
21621        std::fs::remove_dir_all(root).unwrap();
21622    }
21623
21624    #[tokio::test]
21625    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21626        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21627        let lease = install_managed_lease(&shared);
21628        let address = SessionAddress {
21629            workspace_id: WorkspaceId::new("primary").unwrap(),
21630            session: SessionKey {
21631                instance_id: AgentInstanceId(204),
21632                generation: SessionGeneration(1),
21633            },
21634        };
21635        let (overlay, materialization_guard) = shared
21636            .prepare_session_materialization(
21637                &address,
21638                &agent("claude"),
21639                SessionMode::Pty,
21640                Some(&receipt),
21641                None,
21642                None,
21643                Some(lease.lease_id.clone()),
21644            )
21645            .unwrap()
21646            .unwrap();
21647        let materialization_id = materialization_guard.id().unwrap().clone();
21648        let materialization_root = shared
21649            .materializations
21650            .lock()
21651            .unwrap_or_else(|poisoned| poisoned.into_inner())
21652            .get(&materialization_id)
21653            .unwrap()
21654            .root()
21655            .to_path_buf();
21656        let selection = shared
21657            .select_environment_profile(
21658                address.session.instance_id,
21659                &agent("claude"),
21660                SessionMode::Pty,
21661                Some(&receipt),
21662            )
21663            .unwrap()
21664            .unwrap();
21665        assert!(shared
21666            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21667            .unwrap()
21668            .is_none());
21669        shared.bind_session_with_materialization(
21670            &address,
21671            ProviderRuntimePolicy::raw_pty(),
21672            Some(receipt),
21673            None,
21674            None,
21675            Some(materialization_id),
21676        );
21677        selection.retain();
21678        materialization_guard.retain();
21679
21680        let blocked = shared
21681            .cleanup_managed_worktree(&lease.lease_id, false)
21682            .await
21683            .unwrap_err();
21684        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21685        assert!(materialization_root.is_dir());
21686        let removed = shared.remove_binding(&address).unwrap();
21687        shared
21688            .cleanup_session_owned_materialization(&address, &removed)
21689            .unwrap();
21690        assert!(!materialization_root.exists());
21691        drop(shared);
21692        drop(runtime);
21693        std::fs::remove_dir_all(root).unwrap();
21694    }
21695
21696    #[tokio::test]
21697    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21698        let git_root = temporary_workspace_root("raw-managed-inventory");
21699        let source = git_root.join("source");
21700        let allocation = git_root.join("allocation");
21701        std::fs::create_dir_all(&source).unwrap();
21702        std::fs::create_dir_all(&allocation).unwrap();
21703        run_test_git(&source, &["init"]);
21704        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21705        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21706        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21707        run_test_git(&source, &["add", "seed.txt"]);
21708        run_test_git(&source, &["commit", "-m", "seed"]);
21709        let profile = ManagedWorktreeProfile::new(
21710            WorktreeProfileId::new("default").unwrap(),
21711            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21712            &allocation,
21713            "gate4agent",
21714            "HEAD",
21715            ManagedWorktreeRetention::RemoveWhenReleased,
21716        )
21717        .unwrap();
21718        let workspace = WorkspaceConfig::new(
21719            WorkspaceId::new("primary").unwrap(),
21720            &source,
21721        )
21722        .unwrap()
21723        .with_worktree_service_mode(WorktreeServiceMode::Managed)
21724        .with_managed_worktree_profile(profile.clone())
21725        .unwrap();
21726        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21727            materialization_test_shared_with_workspace(false, workspace);
21728        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21729        let source_text = source.to_string_lossy().into_owned();
21730        let target_text = target.to_string_lossy().into_owned();
21731        let created = create_git_worktree(
21732            &source_text,
21733            &target_text,
21734            "gate4agent/mw-raw-inventory",
21735            Some("HEAD"),
21736        )
21737        .await
21738        .unwrap();
21739        let lease = ManagedWorktreeLeaseRecord {
21740            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21741            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21742            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21743            profile_id: profile.profile_id().clone(),
21744            profile_revision: profile.revision().clone(),
21745            target_root: created.path.clone(),
21746            branch: "gate4agent/mw-raw-inventory".to_owned(),
21747            base_commit: created.head.clone(),
21748            expected_head: Some(created.head.clone()),
21749            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21750            state: ManagedWorktreeLeaseState::Ready,
21751            session_holders: Vec::new(),
21752            record_holders: Vec::new(),
21753            cleanup_failure: None,
21754            created_at_unix_ms: 1,
21755            updated_at_unix_ms: 1,
21756        };
21757        shared
21758            .workspaces
21759            .write()
21760            .unwrap_or_else(|poisoned| poisoned.into_inner())
21761            .insert(lease.workspace_id.clone(), created.path.clone());
21762        *shared
21763            .managed_worktrees
21764            .lock()
21765            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21766            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21767        let address = SessionAddress {
21768            workspace_id: lease.workspace_id.clone(),
21769            session: SessionKey {
21770                instance_id: AgentInstanceId(205),
21771                generation: SessionGeneration(1),
21772            },
21773        };
21774        let (overlay, materialization_guard) = shared
21775            .prepare_session_materialization(
21776                &address,
21777                &agent("claude"),
21778                SessionMode::Pty,
21779                Some(&receipt),
21780                None,
21781                None,
21782                Some(lease.lease_id.clone()),
21783            )
21784            .unwrap()
21785            .unwrap();
21786        let materialization_id = materialization_guard.id().unwrap().clone();
21787        let materialization_root = shared
21788            .materializations
21789            .lock()
21790            .unwrap_or_else(|poisoned| poisoned.into_inner())
21791            .get(&materialization_id)
21792            .unwrap()
21793            .root()
21794            .to_path_buf();
21795        let selection = shared
21796            .select_environment_profile(
21797                address.session.instance_id,
21798                &agent("claude"),
21799                SessionMode::Pty,
21800                Some(&receipt),
21801            )
21802            .unwrap()
21803            .unwrap();
21804        assert!(shared
21805            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21806            .unwrap()
21807            .is_none());
21808        let record_id = shared
21809            .bind_spawn_session_with_materialization(
21810                &address,
21811                agent("claude"),
21812                SessionMode::Pty,
21813                ProviderRuntimePolicy::raw_pty(),
21814                SpawnRecordPolicy::Always,
21815                Some(receipt.clone()),
21816                None,
21817                None,
21818                Some(materialization_id.clone()),
21819            )
21820            .unwrap()
21821            .unwrap();
21822        selection.retain();
21823        materialization_guard.retain();
21824        let record = shared.record(&record_id).unwrap();
21825        assert_eq!(record.state, ManagedSessionState::Live);
21826        assert!(record.provider_session.is_none());
21827        assert!(record.environment_profile.is_none());
21828        assert!(record.bundle.is_none());
21829        assert!(record.context_id.is_none());
21830        assert!(record.context.is_none());
21831        assert_eq!(record.active_session.as_ref(), Some(&address));
21832        assert_eq!(
21833            shared
21834                .materializations
21835                .lock()
21836                .unwrap_or_else(|poisoned| poisoned.into_inner())
21837                .get(&materialization_id)
21838                .unwrap()
21839                .owner(),
21840            &MaterializationOwner::Session {
21841                incarnation_id: shared.incarnation_id,
21842                instance_id: address.session.instance_id,
21843                generation: address.session.generation,
21844            },
21845        );
21846        {
21847            let mut bindings = shared
21848                .session_bindings
21849                .lock()
21850                .unwrap_or_else(|poisoned| poisoned.into_inner());
21851            bindings
21852                .get_mut(&address.session.instance_id)
21853                .unwrap()
21854                .managed_worktree_lease_id = Some(lease.lease_id.clone());
21855        }
21856        let lease_snapshot = shared
21857            .managed_worktrees
21858            .lock()
21859            .unwrap_or_else(|poisoned| poisoned.into_inner())
21860            .bind_session(
21861                &lease.lease_id,
21862                ManagedWorktreeSessionHolder {
21863                    incarnation_id: shared.incarnation_id,
21864                    instance_id: address.session.instance_id,
21865                    generation: address.session.generation,
21866                },
21867                None,
21868                unix_time_ms(),
21869            )
21870            .unwrap();
21871        assert_eq!(lease_snapshot.active_session_count, 1);
21872        assert_eq!(lease_snapshot.managed_record_count, 0);
21873        shared.persist_state().unwrap();
21874        assert!(shared
21875            .managed_worktrees
21876            .lock()
21877            .unwrap_or_else(|poisoned| poisoned.into_inner())
21878            .get(&lease.lease_id)
21879            .unwrap()
21880            .record_holders
21881            .is_empty());
21882        shared
21883            .handle
21884            .dispatch(shared.prepare_command(ControlCommand::Register {
21885                instance_id: address.session.instance_id,
21886                agent_id: agent("claude"),
21887                transport: TransportKind::Pty,
21888            }))
21889            .unwrap();
21890        runtime.tick().await;
21891        shared.reconcile_managed_record(
21892            &record_id,
21893            &address,
21894            &ControlEvent {
21895                sequence: 1,
21896                command_id: None,
21897                instance_id: address.session.instance_id,
21898                generation: address.session.generation,
21899                event: ControlEventKind::Removed,
21900            },
21901            false,
21902            false,
21903        );
21904        let remove = shared.remove_session(&address);
21905        let drive_runtime = async {
21906            runtime.tick().await;
21907        };
21908        let (removed, ()) = tokio::join!(remove, drive_runtime);
21909        removed.unwrap();
21910
21911        assert!(!materialization_root.exists());
21912        assert!(!Path::new(&created.path).exists());
21913        assert_eq!(
21914            shared
21915                .managed_worktrees
21916                .lock()
21917                .unwrap_or_else(|poisoned| poisoned.into_inner())
21918                .get(&lease.lease_id)
21919                .unwrap()
21920                .state,
21921            ManagedWorktreeLeaseState::Removed,
21922        );
21923        let renamed = shared
21924            .rename_session_record(&record_id, "raw managed inventory".to_owned())
21925            .unwrap();
21926        assert_eq!(renamed.record_id, record_id);
21927        assert_eq!(renamed.display_name, "raw managed inventory");
21928        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21929        assert!(renamed.active_session.is_none());
21930
21931        drop(shared);
21932        drop(runtime);
21933        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21934        std::fs::remove_dir_all(git_root).unwrap();
21935    }
21936
21937    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21938        let root = std::env::current_dir().unwrap()
21939            .join("managed-test-target")
21940            .to_string_lossy()
21941            .into_owned();
21942        let lease = ManagedWorktreeLeaseRecord {
21943            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21944            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21945            workspace_id: WorkspaceId::new("managed-test").unwrap(),
21946            profile_id: WorktreeProfileId::new("default").unwrap(),
21947            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21948            target_root: root.clone(),
21949            branch: "gate4agent/mw-test".to_owned(),
21950            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21951            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21952            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21953            state: ManagedWorktreeLeaseState::Ready,
21954            session_holders: Vec::new(),
21955            record_holders: Vec::new(),
21956            cleanup_failure: None,
21957            created_at_unix_ms: 1,
21958            updated_at_unix_ms: 1,
21959        };
21960        shared.workspaces.write()
21961            .unwrap_or_else(|poisoned| poisoned.into_inner())
21962            .insert(lease.workspace_id.clone(), root);
21963        *shared.managed_worktrees.lock()
21964            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21965            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21966        lease
21967    }
21968
21969    #[cfg(feature = "fixture")]
21970    #[test]
21971    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21972        let mut shared = terminal_test_shared();
21973        shared.fixture_semantic_hook_policy = true;
21974        let lease = install_managed_lease(&shared);
21975        let address = SessionAddress {
21976            workspace_id: lease.workspace_id.clone(),
21977            session: SessionKey {
21978                instance_id: AgentInstanceId(206),
21979                generation: SessionGeneration(1),
21980            },
21981        };
21982        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21983        let effective_runtime_policy =
21984            shared.effective_spawn_runtime_policy(pre_admission_policy);
21985        assert_ne!(effective_runtime_policy, pre_admission_policy);
21986        assert!(effective_runtime_policy.provider_session_identity);
21987
21988        let record_id = shared
21989            .bind_spawn_session(
21990                &address,
21991                agent("claude"),
21992                SessionMode::Pty,
21993                effective_runtime_policy,
21994                SpawnRecordPolicy::Always,
21995                None,
21996            )
21997            .unwrap()
21998            .unwrap();
21999        assert_eq!(
22000            shared.record(&record_id).unwrap().state,
22001            ManagedSessionState::IdentityPending,
22002        );
22003        let (record_holder, raw_inventory_record_id) = {
22004            let mut bindings = shared
22005                .session_bindings
22006                .lock()
22007                .unwrap_or_else(|poisoned| poisoned.into_inner());
22008            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22009            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22010            managed_spawn_record_ownership(binding, effective_runtime_policy)
22011        };
22012        assert_eq!(record_holder.as_ref(), Some(&record_id));
22013        assert!(raw_inventory_record_id.is_none());
22014        let snapshot = shared
22015            .managed_worktrees
22016            .lock()
22017            .unwrap_or_else(|poisoned| poisoned.into_inner())
22018            .bind_session(
22019                &lease.lease_id,
22020                ManagedWorktreeSessionHolder {
22021                    incarnation_id: shared.incarnation_id,
22022                    instance_id: address.session.instance_id,
22023                    generation: address.session.generation,
22024                },
22025                record_holder,
22026                unix_time_ms(),
22027            )
22028            .unwrap();
22029        assert_eq!(snapshot.managed_record_count, 1);
22030        assert_eq!(
22031            shared
22032                .managed_worktrees
22033                .lock()
22034                .unwrap_or_else(|poisoned| poisoned.into_inner())
22035                .get(&lease.lease_id)
22036                .unwrap()
22037                .record_holders,
22038            vec![record_id.clone()],
22039        );
22040
22041        let identity = ProviderSessionIdentity {
22042            key: ProviderSessionKey::SessionId,
22043            id: "monitoring-fixture-managed-session".to_owned(),
22044            transcript_path: None,
22045        };
22046        shared.publish_control(ControlEvent {
22047            sequence: 1,
22048            command_id: None,
22049            instance_id: address.session.instance_id,
22050            generation: address.session.generation,
22051            event: ControlEventKind::ProviderEvent {
22052                sequence: 1,
22053                source: gate4agent_types::ProviderSource {
22054                    family: AdapterFamily::Hook,
22055                    binding: AdapterBinding::new(
22056                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
22057                        "fixture/v1",
22058                        gate4agent_types::AdapterVerification::SyntheticFixture,
22059                    )
22060                    .unwrap(),
22061                },
22062                source_sequence: 1,
22063                event: ProviderEvent::SessionIdentityObserved {
22064                    identity: identity.clone(),
22065                },
22066            },
22067        });
22068
22069        let live = shared.record(&record_id).unwrap();
22070        assert_eq!(live.state, ManagedSessionState::Live);
22071        assert_eq!(live.provider_session.as_ref(), Some(&identity));
22072        assert_eq!(live.active_session.as_ref(), Some(&address));
22073        assert_eq!(
22074            shared
22075                .managed_worktrees
22076                .lock()
22077                .unwrap_or_else(|poisoned| poisoned.into_inner())
22078                .get(&lease.lease_id)
22079                .unwrap()
22080                .record_holders,
22081            vec![record_id],
22082        );
22083    }
22084
22085    fn managed_v2_test_shared(
22086        allocation_root: &Path,
22087        state_path: Option<PathBuf>,
22088        profile_revision: &str,
22089    ) -> NodeShared {
22090        let profile = ManagedWorktreeProfile::new(
22091            WorktreeProfileId::new("default").unwrap(),
22092            WorktreeProfileRevision::new(profile_revision).unwrap(),
22093            allocation_root,
22094            "gate4agent",
22095            "HEAD",
22096            ManagedWorktreeRetention::RemoveWhenReleased,
22097        )
22098        .unwrap();
22099        let workspace = WorkspaceConfig::new(
22100            WorkspaceId::new("primary").unwrap(),
22101            std::env::current_dir().unwrap(),
22102        )
22103        .unwrap()
22104        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22105        .with_managed_worktree_profile(profile)
22106        .unwrap();
22107        let catalog = active_registry().unwrap();
22108        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22109        let mut shared = NodeShared::new(
22110            handle,
22111            "fixture-token".to_owned(),
22112            NodeId::new("node-terminal-test").unwrap(),
22113            vec![workspace],
22114            vec![agent("claude")],
22115        );
22116        shared.state_path = state_path;
22117        shared
22118    }
22119
22120    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22121        let mut spawn_spec = spawn_spec_fixture();
22122        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22123        ManagedWorktreeSpawnRequestV2 {
22124            spawn_spec,
22125            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22126            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22127        }
22128    }
22129
22130    #[test]
22131    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22132        let shared = terminal_test_shared();
22133        let mut lease = install_managed_lease(&shared);
22134        let session = SessionAddress {
22135            workspace_id: lease.workspace_id.clone(),
22136            session: SessionKey {
22137                instance_id: AgentInstanceId(73),
22138                generation: SessionGeneration(4),
22139            },
22140        };
22141        lease.state = ManagedWorktreeLeaseState::InUse;
22142        lease.session_holders.push(ManagedWorktreeSessionHolder {
22143            incarnation_id: shared.incarnation_id,
22144            instance_id: session.session.instance_id,
22145            generation: session.session.generation,
22146        });
22147        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22148        assert!(resolved.target.worktree_id.is_none());
22149
22150        let receipt = managed_spawn_receipt(
22151            &resolved,
22152            shared.incarnation_id,
22153            session.clone(),
22154            lease.snapshot(),
22155            None,
22156            None,
22157            None,
22158        );
22159        assert!(resolved.target.worktree_id.is_none());
22160        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22161        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22162        assert_eq!(receipt.spawn.session, session);
22163        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22164        let encoded = serde_json::to_vec(&receipt).unwrap();
22165        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22166        assert_eq!(decoded, receipt);
22167    }
22168
22169    #[tokio::test]
22170    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22171        let root = temporary_workspace_root("managed-v2-revision");
22172        let allocation = root.join("allocation");
22173        std::fs::create_dir_all(&allocation).unwrap();
22174        let shared = managed_v2_test_shared(
22175            &allocation,
22176            Some(root.join("node-state.json")),
22177            "v1",
22178        );
22179        let mut request = managed_v2_request("managed-v2-revision");
22180        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22181
22182        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22183
22184        assert_eq!(
22185            failure.code,
22186            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22187        );
22188        assert!(shared
22189            .managed_worktrees
22190            .lock()
22191            .unwrap_or_else(|poisoned| poisoned.into_inner())
22192            .records()
22193            .is_empty());
22194        assert!(shared
22195            .managed_spawn_replays
22196            .lock()
22197            .unwrap_or_else(|poisoned| poisoned.into_inner())
22198            .is_empty());
22199        std::fs::remove_dir_all(root).unwrap();
22200    }
22201
22202    #[test]
22203    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22204        let root = temporary_workspace_root("managed-v2-concurrent");
22205        let allocation = root.join("allocation");
22206        std::fs::create_dir_all(&allocation).unwrap();
22207        let shared = Arc::new(managed_v2_test_shared(
22208            &allocation,
22209            Some(root.join("node-state.json")),
22210            "v1",
22211        ));
22212        let request = managed_v2_request("managed-v2-concurrent");
22213        let barrier = Arc::new(std::sync::Barrier::new(3));
22214        let mut joins = Vec::new();
22215        for _ in 0..2 {
22216            let shared = Arc::clone(&shared);
22217            let request = request.clone();
22218            let barrier = Arc::clone(&barrier);
22219            joins.push(std::thread::spawn(move || {
22220                let digest = managed_spawn_request_digest_v2(&request).unwrap();
22221                barrier.wait();
22222                shared.acquire_durable_managed_spawn_v2(&request, digest)
22223            }));
22224        }
22225        barrier.wait();
22226        let results = joins
22227            .into_iter()
22228            .map(|join| join.join().unwrap())
22229            .collect::<Vec<_>>();
22230        assert_eq!(
22231            results
22232                .iter()
22233                .filter(|result| {
22234                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22235                })
22236                .count(),
22237            1,
22238        );
22239        assert_eq!(
22240            results
22241                .iter()
22242                .filter(|result| {
22243                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22244                })
22245                .count(),
22246            1,
22247        );
22248        assert_eq!(
22249            shared
22250                .managed_spawn_replays
22251                .lock()
22252                .unwrap_or_else(|poisoned| poisoned.into_inner())
22253                .len(),
22254            1,
22255        );
22256        assert!(shared
22257            .managed_worktrees
22258            .lock()
22259            .unwrap_or_else(|poisoned| poisoned.into_inner())
22260            .records()
22261            .is_empty());
22262        drop(shared);
22263        std::fs::remove_dir_all(root).unwrap();
22264    }
22265
22266    #[tokio::test]
22267    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22268        let root = temporary_workspace_root("managed-v2-linked-pending");
22269        let allocation = root.join("allocation");
22270        std::fs::create_dir_all(&allocation).unwrap();
22271        let state_path = root.join("node-state.json");
22272        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22273        let lease = install_managed_lease(&shared);
22274        let request = managed_v2_request("managed-v2-linked-pending");
22275        assert!(matches!(
22276            shared
22277                .acquire_durable_managed_spawn_v2(
22278                    &request,
22279                    managed_spawn_request_digest_v2(&request).unwrap(),
22280                )
22281                .unwrap(),
22282            DurableManagedSpawnReplayDecision::Reserved,
22283        ));
22284        {
22285            let _transaction = shared
22286                .state_transaction
22287                .lock()
22288                .unwrap_or_else(|poisoned| poisoned.into_inner());
22289            shared
22290                .link_durable_managed_spawn_v2_lease_locked(
22291                    &request.spawn_spec.idempotency_key,
22292                    &lease.lease_id,
22293                )
22294                .unwrap();
22295            shared.persist_state_locked().unwrap();
22296        }
22297        drop(shared);
22298
22299        let mut loaded = session_registry::load(
22300            Some(&state_path),
22301            &NodeId::new("node-terminal-test").unwrap(),
22302        )
22303        .unwrap();
22304        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22305        *reopened
22306            .managed_worktrees
22307            .lock()
22308            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22309            ManagedWorktreeRegistry::from_records(
22310                std::mem::take(&mut loaded.managed_worktrees),
22311                std::mem::take(&mut loaded.managed_worktree_tombstones),
22312            )
22313            .unwrap();
22314        *reopened
22315            .managed_spawn_replays
22316            .lock()
22317            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22318            .managed_spawn_replays
22319            .into_iter()
22320            .map(|record| (record.idempotency_key.clone(), record))
22321            .collect();
22322        let lease_count = reopened
22323            .managed_worktrees
22324            .lock()
22325            .unwrap_or_else(|poisoned| poisoned.into_inner())
22326            .records()
22327            .len();
22328
22329        let failure = reopened
22330            .spawn_managed_worktree_v2(request)
22331            .await
22332            .unwrap_err();
22333
22334        assert_eq!(
22335            failure.code,
22336            NodeFailureCode::ManagedWorktreeRecoveryRequired,
22337        );
22338        assert_eq!(
22339            reopened
22340                .managed_worktrees
22341                .lock()
22342                .unwrap_or_else(|poisoned| poisoned.into_inner())
22343                .records()
22344                .len(),
22345            lease_count,
22346        );
22347        std::fs::remove_dir_all(root).unwrap();
22348    }
22349
22350    #[tokio::test]
22351    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22352        let root = temporary_workspace_root("managed-v2-reopen");
22353        let allocation = root.join("allocation");
22354        std::fs::create_dir_all(&allocation).unwrap();
22355        let state_path = root.join("node-state.json");
22356        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22357        let lease = install_managed_lease(&shared);
22358        let request = managed_v2_request("managed-v2-reopen");
22359        let session = SessionAddress {
22360            workspace_id: lease.workspace_id.clone(),
22361            session: SessionKey {
22362                instance_id: AgentInstanceId(91),
22363                generation: SessionGeneration(1),
22364            },
22365        };
22366        let snapshot = {
22367            let mut registry = shared
22368                .managed_worktrees
22369                .lock()
22370                .unwrap_or_else(|poisoned| poisoned.into_inner());
22371            let current = registry.get_mut(&lease.lease_id).unwrap();
22372            current.state = ManagedWorktreeLeaseState::InUse;
22373            current.session_holders.push(ManagedWorktreeSessionHolder {
22374                incarnation_id: shared.incarnation_id,
22375                instance_id: session.session.instance_id,
22376                generation: session.session.generation,
22377            });
22378            current.snapshot()
22379        };
22380        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22381        let receipt = managed_spawn_receipt(
22382            &resolved,
22383            shared.incarnation_id,
22384            session,
22385            snapshot,
22386            None,
22387            None,
22388            None,
22389        );
22390        assert!(matches!(
22391            shared
22392                .acquire_durable_managed_spawn_v2(
22393                    &request,
22394                    managed_spawn_request_digest_v2(&request).unwrap(),
22395                )
22396                .unwrap(),
22397            DurableManagedSpawnReplayDecision::Reserved,
22398        ));
22399        {
22400            let _transaction = shared
22401                .state_transaction
22402                .lock()
22403                .unwrap_or_else(|poisoned| poisoned.into_inner());
22404            shared
22405                .link_durable_managed_spawn_v2_lease_locked(
22406                    &request.spawn_spec.idempotency_key,
22407                    &lease.lease_id,
22408                )
22409                .unwrap();
22410            shared.persist_state_locked().unwrap();
22411        }
22412        shared
22413            .commit_durable_managed_spawn_v2(&request, receipt.clone())
22414            .unwrap();
22415        let legacy_same_key = ManagedWorktreeSpawnRequest {
22416            spawn_spec: request.spawn_spec.clone(),
22417            worktree_profile_id: request.worktree_profile_id.clone(),
22418        };
22419        shared.remember_managed_spawn_attempt(
22420            Some(&request.spawn_spec.idempotency_key),
22421            legacy_same_key.clone(),
22422            Ok(receipt.clone()),
22423            Instant::now(),
22424        );
22425        assert!(shared
22426            .replay_managed_spawn(&legacy_same_key, Instant::now())
22427            .unwrap()
22428            .is_none());
22429        drop(shared);
22430
22431        let mut loaded = session_registry::load(
22432            Some(&state_path),
22433            &NodeId::new("node-terminal-test").unwrap(),
22434        )
22435        .unwrap();
22436        assert_eq!(loaded.managed_spawn_replays.len(), 1);
22437        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22438        *reopened
22439            .managed_worktrees
22440            .lock()
22441            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22442            ManagedWorktreeRegistry::from_records(
22443                std::mem::take(&mut loaded.managed_worktrees),
22444                std::mem::take(&mut loaded.managed_worktree_tombstones),
22445            )
22446            .unwrap();
22447        *reopened
22448            .managed_spawn_replays
22449            .lock()
22450            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22451            .managed_spawn_replays
22452            .into_iter()
22453            .map(|record| (record.idempotency_key.clone(), record))
22454            .collect();
22455        let lease_count_before = reopened
22456            .managed_worktrees
22457            .lock()
22458            .unwrap_or_else(|poisoned| poisoned.into_inner())
22459            .records()
22460            .len();
22461
22462        assert_eq!(
22463            reopened
22464                .spawn_managed_worktree_v2(request.clone())
22465                .await
22466                .unwrap(),
22467            receipt,
22468        );
22469        assert_eq!(
22470            reopened
22471                .managed_worktrees
22472                .lock()
22473                .unwrap_or_else(|poisoned| poisoned.into_inner())
22474                .records()
22475                .len(),
22476            lease_count_before,
22477        );
22478        let mut changed = request;
22479        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22480        let failure = reopened
22481            .spawn_managed_worktree_v2(changed)
22482            .await
22483            .unwrap_err();
22484        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22485        assert_eq!(
22486            reopened
22487                .managed_worktrees
22488                .lock()
22489                .unwrap_or_else(|poisoned| poisoned.into_inner())
22490                .records()
22491                .len(),
22492            lease_count_before,
22493        );
22494        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22495        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22496        let failure = reopened
22497            .spawn_managed_worktree_v2(new_key)
22498            .await
22499            .unwrap_err();
22500        assert_eq!(
22501            failure.code,
22502            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22503        );
22504        assert_eq!(
22505            reopened
22506                .managed_worktrees
22507                .lock()
22508                .unwrap_or_else(|poisoned| poisoned.into_inner())
22509                .records()
22510                .len(),
22511            lease_count_before,
22512        );
22513        assert_eq!(
22514            reopened
22515                .managed_spawn_replays
22516                .lock()
22517                .unwrap_or_else(|poisoned| poisoned.into_inner())
22518                .len(),
22519            1,
22520        );
22521        std::fs::remove_dir_all(root).unwrap();
22522    }
22523
22524    #[test]
22525    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22526        let shared = terminal_test_shared();
22527        let request = ManagedWorktreeSpawnRequest {
22528            spawn_spec: spawn_spec_fixture(),
22529            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22530        };
22531        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22532        shared.remember_managed_spawn(
22533            request.clone(),
22534            Err(expected.clone()),
22535            Instant::now(),
22536        );
22537
22538        assert_eq!(
22539            shared
22540                .replay_managed_spawn(&request, Instant::now())
22541                .unwrap()
22542                .unwrap(),
22543            Err(expected),
22544        );
22545        assert!(shared
22546            .managed_spawn_replays
22547            .lock()
22548            .unwrap_or_else(|poisoned| poisoned.into_inner())
22549            .is_empty());
22550    }
22551
22552    #[tokio::test]
22553    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22554        let shared = terminal_test_shared();
22555        let lease = install_managed_lease(&shared);
22556        let raw = shared.spawn_session(
22557            lease.workspace_id.clone(),
22558            agent("claude"),
22559            SessionMode::Pty,
22560            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22561            None,
22562        ).await.unwrap_err();
22563        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22564        let mut spec = spawn_spec_fixture();
22565        spec.target.workspace_id = lease.workspace_id.clone();
22566        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22567        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22568        let mut selected = spawn_spec_fixture();
22569        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22570        selected.target.worktree_id = Some(lease.workspace_id);
22571        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22572        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22573        assert!(shared.handle.snapshot().sessions.is_empty());
22574    }
22575
22576    #[test]
22577    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22578        let shared = terminal_test_shared();
22579        let lease = install_managed_lease(&shared);
22580        let snapshot = snapshot_for_wire(
22581            &shared, true, true, true, false, true, true, false, false, true,
22582        );
22583        assert!(snapshot.managed_worktrees.is_empty());
22584        assert!(snapshot.workspaces.iter().all(|workspace| {
22585            workspace.managed_worktree_profiles.is_none()
22586        }));
22587        let mut reply = ResponseEnvelope {
22588            request_id: 1,
22589            result: Ok(NodeResponse::Resync {
22590                event_sequence: 1,
22591                oldest_available_sequence: 1,
22592                snapshot: shared.snapshot(),
22593                events: vec![NodeEventEnvelope {
22594                    sequence: 1,
22595                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22596                }],
22597            }),
22598        };
22599        project_response_without_managed_worktrees(&mut reply);
22600        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22601            panic!("projection changed the response kind");
22602        };
22603        assert!(snapshot.managed_worktrees.is_empty());
22604        assert!(events.is_empty());
22605        let mut git = git_snapshot_for_parser();
22606        git.branch = Some(lease.branch.clone());
22607        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22608            lease_id: lease.lease_id.clone(),
22609            source_workspace_id: lease.source_workspace_id.clone(),
22610            branch: lease.branch.clone(),
22611            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22612            active_session_count: 1,
22613            managed_record_count: 0,
22614        });
22615        let mut inspection_reply = ResponseEnvelope {
22616            request_id: 2,
22617            result: Ok(NodeResponse::WorkspaceInspected {
22618                inspection: WorkspaceInspection {
22619                    workspace_id: lease.workspace_id.clone(),
22620                    entries: Vec::new(),
22621                    tree_truncated: false,
22622                    git,
22623                    truncation: None,
22624                },
22625            }),
22626        };
22627        project_response_without_managed_worktrees(&mut inspection_reply);
22628        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22629            panic!("projection changed the inspection response kind");
22630        };
22631        assert!(inspection.git.managed_worktree.is_none());
22632        let failure = managed_git_worktree_failure(GitWorktreeError {
22633            kind: GitWorktreeErrorKind::Failed,
22634            message: r"git failed at C:\private\managed-target".to_owned(),
22635        });
22636        assert!(!failure.message.contains("private"));
22637        assert!(!failure.message.contains("managed-target"));
22638    }
22639
22640    #[tokio::test]
22641    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22642        let root = temporary_workspace_root("managed-restart");
22643        let source = root.join("source");
22644        let allocation = root.join("allocation");
22645        std::fs::create_dir_all(&source).unwrap();
22646        std::fs::create_dir_all(&allocation).unwrap();
22647        run_test_git(&source, &["init"]);
22648        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22649        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22650        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22651        run_test_git(&source, &["add", "seed.txt"]);
22652        run_test_git(&source, &["commit", "-m", "seed"]);
22653        let profile = ManagedWorktreeProfile::new(
22654            WorktreeProfileId::new("default").unwrap(),
22655            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22656            &allocation,
22657            "gate4agent",
22658            "HEAD",
22659            ManagedWorktreeRetention::Retain,
22660        ).unwrap();
22661        let workspace = WorkspaceConfig::new(
22662            WorkspaceId::new("primary").unwrap(),
22663            &source,
22664        ).unwrap()
22665            .with_worktree_service_mode(WorktreeServiceMode::Managed)
22666            .with_managed_worktree_profile(profile.clone()).unwrap();
22667        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22668        let source_text = workspace.canonical_root().to_owned();
22669        let target_text = target.to_string_lossy().into_owned();
22670        let created = create_git_worktree(
22671            &source_text,
22672            &target_text,
22673            "gate4agent/mw-restart",
22674            Some("HEAD"),
22675        ).await.unwrap();
22676        let lease = ManagedWorktreeLeaseRecord {
22677            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22678            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22679            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22680            profile_id: profile.profile_id().clone(),
22681            profile_revision: profile.revision().clone(),
22682            target_root: created.path.clone(),
22683            branch: "gate4agent/mw-restart".to_owned(),
22684            base_commit: created.head.clone(),
22685            expected_head: None,
22686            retention: ManagedWorktreeRetention::Retain,
22687            state: ManagedWorktreeLeaseState::Allocating,
22688            session_holders: Vec::new(),
22689            record_holders: Vec::new(),
22690            cleanup_failure: None,
22691            created_at_unix_ms: 1,
22692            updated_at_unix_ms: 1,
22693        };
22694        let catalog = active_registry().unwrap();
22695        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22696        let shared = NodeShared::new_with_incarnation(
22697            handle,
22698            "fixture-token".to_owned(),
22699            NodeId::new("node-managed-restart").unwrap(),
22700            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22701            vec![workspace],
22702            vec![agent("claude")],
22703            ProviderRuntimeStatuses::default(),
22704            None,
22705            Vec::new(),
22706            Vec::new(),
22707            SpawnProfileRegistry::default(),
22708            None,
22709            None,
22710            None,
22711            None,
22712            Vec::new(),
22713            vec![lease.clone()],
22714            Vec::new(),
22715            Vec::new(),
22716            None,
22717            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22718        );
22719        shared.reconcile_managed_worktrees().await;
22720        let retained = shared.managed_worktrees.lock()
22721            .unwrap_or_else(|poisoned| poisoned.into_inner())
22722            .get(&lease.lease_id).unwrap().clone();
22723        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22724        assert!(shared.workspaces.read()
22725            .unwrap_or_else(|poisoned| poisoned.into_inner())
22726            .contains_key(&lease.workspace_id));
22727        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22728        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22729        assert!(!Path::new(&created.path).exists());
22730        std::fs::remove_dir_all(root).unwrap();
22731    }
22732
22733    fn run_test_git(root: &Path, arguments: &[&str]) {
22734        let output = std::process::Command::new("git")
22735            .arg("-C").arg(root).args(arguments).output().unwrap();
22736        assert!(
22737            output.status.success(),
22738            "git {:?} failed: {}",
22739            arguments,
22740            String::from_utf8_lossy(&output.stderr),
22741        );
22742    }
22743
22744    #[tokio::test]
22745    async fn spawn_spec_validation_fails_before_session_mutation() {
22746        let shared = terminal_test_shared();
22747        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22748            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22749        }));
22750        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22751            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22752        }));
22753        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22754            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22755        }));
22756
22757        let mut target_mismatch = spawn_spec_fixture();
22758        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22759        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22760        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22761
22762        let mut unknown_profile = spawn_spec_fixture();
22763        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22764        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22765        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22766        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22767
22768        let mut revision_mismatch = spawn_spec_fixture();
22769        revision_mismatch.idempotency_key =
22770            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22771        revision_mismatch.expected_profile_revision =
22772            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22773        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22774        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22775
22776        let mut unsupported_materializer = spawn_spec_fixture();
22777        unsupported_materializer.idempotency_key =
22778            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22779        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22780            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22781        };
22782        let failure = shared
22783            .spawn_from_spec(unsupported_materializer)
22784            .await
22785            .unwrap_err();
22786        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22787
22788        let failure = shared
22789            .spawn_session_with_deadline(
22790                WorkspaceId::new("primary").unwrap(),
22791                agent("claude"),
22792                SessionMode::Pty,
22793                gate4agent_types::TerminalSize {
22794                    rows: 24,
22795                    columns: 80,
22796                },
22797                None,
22798                None,
22799                None,
22800                None,
22801                None,
22802                None,
22803                SpawnRecordPolicy::ProviderIdentityOnly,
22804                Some(Instant::now()),
22805                &[],
22806                None,
22807                ApprovalLevel::default(),
22808                None,
22809                None,
22810            )
22811            .await
22812            .unwrap_err();
22813        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22814        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22815        assert!(shared.handle.snapshot().sessions.is_empty());
22816    }
22817
22818    #[test]
22819    fn worktree_selection_requires_its_separate_negotiated_capability() {
22820        let mut spec = spawn_spec_fixture();
22821        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22822        let request = NodeRequest::SpawnSpec { spec };
22823        let spawn_spec_capability =
22824            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22825        assert!(request_uses_unnegotiated_capability(
22826            &request,
22827            &[spawn_spec_capability.clone()],
22828        ));
22829        let worktree_capability =
22830            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22831        assert!(request_uses_unnegotiated_capability(
22832            &request,
22833            &[spawn_spec_capability.clone(), worktree_capability.clone()],
22834        ));
22835        let profile_revision_capability =
22836            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22837        assert!(request_uses_unnegotiated_capability(
22838            &request,
22839            &[
22840                spawn_spec_capability.clone(),
22841                worktree_capability.clone(),
22842                profile_revision_capability.clone(),
22843            ],
22844        ));
22845        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
22846        // `Inherit`, not `Clear` — the profile's own defaults might still
22847        // materialize a bundle or a context pack, so both capabilities stay
22848        // conservatively required until negotiated (see
22849        // `requires_session_bundle_materialization_capability` /
22850        // `requires_history_context_pack_capability`).
22851        let bundle_materialization_capability =
22852            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22853        assert!(request_uses_unnegotiated_capability(
22854            &request,
22855            &[
22856                spawn_spec_capability.clone(),
22857                worktree_capability.clone(),
22858                profile_revision_capability.clone(),
22859                bundle_materialization_capability.clone(),
22860            ],
22861        ));
22862        let history_context_pack_capability =
22863            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22864        assert!(!request_uses_unnegotiated_capability(
22865            &request,
22866            &[
22867                spawn_spec_capability,
22868                worktree_capability,
22869                profile_revision_capability,
22870                bundle_materialization_capability,
22871                history_context_pack_capability,
22872            ],
22873        ));
22874    }
22875
22876    #[tokio::test]
22877    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22878        let mut shared = terminal_test_shared();
22879        let spec = spawn_spec_fixture();
22880        let receipt = shared
22881            .resolve_spawn_spec(&spec)
22882            .unwrap()
22883            .receipt(shared.incarnation_id, terminal_address(1));
22884        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22885
22886        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22887        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22888        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22889
22890        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22891
22892        let mut revision_conflicting = spec.clone();
22893        revision_conflicting.expected_profile_revision =
22894            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22895        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22896        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22897
22898        let mut stale_cache_miss = spec.clone();
22899        stale_cache_miss.idempotency_key =
22900            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22901        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22902        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22903
22904        let mut conflicting = spec;
22905        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22906        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22907        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22908        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22909        assert!(shared.handle.snapshot().sessions.is_empty());
22910    }
22911
22912    #[tokio::test]
22913    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22914        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22915        let mut spec = spawn_spec_fixture();
22916        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22917        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22918        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22919
22920        let failure = shared
22921            .create_worktree(
22922                WorkspaceId::new("primary").unwrap(),
22923                WorkspaceId::new("new-worktree").unwrap(),
22924                "not-an-absolute-path".to_owned(),
22925                "invalid branch".to_owned(),
22926                None,
22927            )
22928            .await
22929            .unwrap_err();
22930        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22931
22932        let failure = shared
22933            .remove_worktree(
22934                WorkspaceId::new("primary").unwrap(),
22935                "not-an-absolute-path".to_owned(),
22936            )
22937            .await
22938            .unwrap_err();
22939        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22940        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22941        assert!(shared.handle.snapshot().sessions.is_empty());
22942    }
22943
22944    #[tokio::test]
22945    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22946        let root = temporary_workspace_root("manual-worktree-compensation");
22947        let source = root.join("source");
22948        let target = root.join("target");
22949        let invalid_state_parent = root.join("state-parent-is-a-file");
22950        let invalid_state_path = invalid_state_parent.join("state.json");
22951        std::fs::create_dir_all(&source).unwrap();
22952        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22953        run_test_git(&source, &["init"]);
22954        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22955        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22956        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22957        run_test_git(&source, &["add", "seed.txt"]);
22958        run_test_git(&source, &["commit", "-m", "seed"]);
22959
22960        let catalog = active_registry().unwrap();
22961        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22962        let workspace = WorkspaceConfig::new(
22963            WorkspaceId::new("primary").unwrap(),
22964            &source,
22965        ).unwrap();
22966        let mut shared = NodeShared::new(
22967            handle,
22968            "fixture-token".to_owned(),
22969            NodeId::new("node-worktree-compensation").unwrap(),
22970            vec![workspace],
22971            vec![agent("claude")],
22972        );
22973        shared.state_path = Some(invalid_state_path);
22974
22975        let error = shared.create_worktree(
22976            WorkspaceId::new("primary").unwrap(),
22977            WorkspaceId::new("compensated").unwrap(),
22978            target.to_string_lossy().into_owned(),
22979            "gate4agent/compensated".to_owned(),
22980            Some("HEAD".to_owned()),
22981        ).await.unwrap_err();
22982
22983        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22984        assert!(!target.exists());
22985        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22986            .iter()
22987            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22988        std::fs::remove_dir_all(root).unwrap();
22989    }
22990
22991    #[test]
22992    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22993        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22994            NativeGitWorktreeSnapshot {
22995                path: path.to_owned(),
22996                head: "0123456789abcdef".to_owned(),
22997                branch: Some("refs/heads/test".to_owned()),
22998                is_bare,
22999                is_main,
23000                locked: false,
23001                lock_reason: None,
23002                prunable: false,
23003                prunable_reason: None,
23004                workspace_id: None,
23005            }
23006        }
23007
23008        let source = r"C:\repo";
23009        let selected = r"C:\repo-worktree";
23010        let listed = vec![
23011            worktree(source, true, false),
23012            worktree(selected, false, false),
23013        ];
23014        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23015
23016        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23017        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23018
23019        let bare = vec![
23020            worktree(source, true, false),
23021            worktree(selected, false, true),
23022        ];
23023        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23024        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23025
23026        let foreign_failure =
23027            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23028        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23029    }
23030
23031    fn terminal_frame(sequence: u64) -> TerminalFrame {
23032        TerminalFrame {
23033            sequence,
23034            size: gate4agent_types::TerminalSize {
23035                rows: 24,
23036                columns: 80,
23037            },
23038            cursor_row: 2,
23039            cursor_column: 3,
23040            contents: format!("frame-{sequence}"),
23041            formatted: vec![1, 2, 3],
23042            scrollback_formatted: Vec::new(),
23043            alternate_screen: false,
23044            mouse_protocol_enabled: false,
23045            mouse_protocol_encoding: Default::default(),
23046            produced_at_unix_ms: 0,
23047            screen_state: gate4agent_types::PtyScreenState::default(),
23048            bracketed_paste: None,
23049        }
23050    }
23051
23052    #[test]
23053    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23054        let shared = terminal_test_shared();
23055        let mut receiver = shared.terminal_event_tx.subscribe();
23056        let original = terminal_address(1);
23057
23058        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23059        let first = receiver.try_recv().unwrap();
23060        assert_eq!(first.len(), 1);
23061        assert!(matches!(
23062            &first[0].event,
23063            NodeEvent::TerminalFrame { address, frame }
23064                if address == &original && frame.sequence == 7
23065        ));
23066
23067        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23068        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23069        assert!(matches!(
23070            receiver.try_recv(),
23071            Err(broadcast::error::TryRecvError::Empty)
23072        ));
23073
23074        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23075        let advanced = receiver.try_recv().unwrap();
23076        assert_eq!(advanced[0].sequence, 2);
23077
23078        let resumed = terminal_address(2);
23079        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23080        let reset = receiver.try_recv().unwrap();
23081        assert_eq!(reset[0].sequence, 3);
23082        assert!(matches!(
23083            &reset[0].event,
23084            NodeEvent::TerminalFrame { address, frame }
23085                if address == &resumed && frame.sequence == 1
23086        ));
23087        shared.clear_terminal_frame_watermark(&resumed);
23088        assert!(!shared
23089            .terminal_frame_watermarks
23090            .lock()
23091            .unwrap_or_else(|poisoned| poisoned.into_inner())
23092            .contains_key(&resumed.session.instance_id));
23093    }
23094
23095    #[test]
23096    fn terminal_frame_batches_replace_without_entering_node_event_history() {
23097        let shared = terminal_test_shared();
23098        let mut receiver = shared.terminal_event_tx.subscribe();
23099        let address = terminal_address(1);
23100        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23101
23102        for sequence in 1..=count {
23103            shared.publish_terminal_frame_candidates(vec![(
23104                address.clone(),
23105                terminal_frame(sequence),
23106            )]);
23107        }
23108
23109        let history = shared
23110            .history
23111            .lock()
23112            .unwrap_or_else(|poisoned| poisoned.into_inner());
23113        assert!(history.events.is_empty());
23114        assert_eq!(history.last_sequence, count);
23115        assert_eq!(history.replay_floor_sequence, 1);
23116        drop(history);
23117        let NodeResponse::Resync {
23118            event_sequence,
23119            oldest_available_sequence,
23120            events,
23121            ..
23122        } = shared.resync(0) else {
23123            unreachable!("resync helper returned another response")
23124        };
23125        assert_eq!(event_sequence, count);
23126        assert_eq!(oldest_available_sequence, 1);
23127        assert!(events.is_empty());
23128        assert!(matches!(
23129            resync_required_event(&shared).event,
23130            NodeEvent::ResyncRequired {
23131                oldest_available_sequence: 1,
23132            }
23133        ));
23134        assert!(matches!(
23135            receiver.try_recv(),
23136            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23137        ));
23138        let latest = receiver.try_recv().unwrap();
23139        assert_eq!(latest.len(), 1);
23140        assert_eq!(latest[0].sequence, count);
23141    }
23142
23143    #[test]
23144    fn real_durable_eviction_advances_authoritative_replay_floor() {
23145        let shared = terminal_test_shared();
23146        for _ in 0..=NODE_EVENT_HISTORY_MAX {
23147            shared.publish(NodeEvent::ControllerChanged { controller: None });
23148        }
23149
23150        let NodeResponse::Resync {
23151            event_sequence,
23152            oldest_available_sequence,
23153            events,
23154            ..
23155        } = shared.resync(0) else {
23156            unreachable!("resync helper returned another response")
23157        };
23158        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23159        assert_eq!(oldest_available_sequence, 2);
23160        assert_eq!(events.first().map(|event| event.sequence), Some(2));
23161        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23162        assert!(matches!(
23163            resync_required_event(&shared).event,
23164            NodeEvent::ResyncRequired {
23165                oldest_available_sequence: 2,
23166            }
23167        ));
23168    }
23169
23170    #[test]
23171    fn legacy_terminal_frame_connection_has_no_live_subscription() {
23172        let shared = terminal_test_shared();
23173        let without_capability = terminal_event_subscription(&shared, false);
23174        let with_capability = terminal_event_subscription(&shared, true);
23175        assert!(without_capability.is_none());
23176        assert!(with_capability.is_some());
23177        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23178            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23179        }));
23180    }
23181
23182    #[test]
23183    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23184        let mut pending = Vec::new();
23185        let mut event_sequence = 0_u64;
23186        for frame_sequence in 1..=4 {
23187            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23188                event_sequence += 1;
23189                let mut address = terminal_address(1);
23190                address.session.instance_id = AgentInstanceId(instance);
23191                queue_connection_event(
23192                    &mut pending,
23193                    NodeEventEnvelope {
23194                        sequence: event_sequence,
23195                        event: NodeEvent::TerminalFrame {
23196                            address,
23197                            frame: terminal_frame(frame_sequence),
23198                        },
23199                    },
23200                    0,
23201                );
23202            }
23203        }
23204        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23205        pending.sort_unstable_by_key(|event| event.sequence);
23206
23207        let mut uninterrupted = pending.clone();
23208        let first_burst = connection_event_burst_len(uninterrupted.len());
23209        uninterrupted.drain(..first_burst);
23210        let second_burst = connection_event_burst_len(uninterrupted.len());
23211        uninterrupted.drain(..second_burst);
23212        assert!(uninterrupted.is_empty());
23213
23214        let (frame_tx, mut frame_rx) =
23215            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23216        frame_tx
23217            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23218                request_id: 77,
23219                request: NodeRequest::Snapshot,
23220            })))
23221            .unwrap();
23222        let burst_len = connection_event_burst_len(pending.len());
23223        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23224        pending.drain(..burst_len);
23225        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23226        assert!(matches!(
23227            frame_rx.try_recv(),
23228            Ok(Ok(ClientFrame::Request(RequestEnvelope {
23229                request_id: 77,
23230                request: NodeRequest::Snapshot,
23231            })))
23232        ));
23233    }
23234
23235    #[test]
23236    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23237        let mut pending = Vec::new();
23238        for sequence in [7_u64, 8] {
23239            let mut address = terminal_address(1);
23240            address.session.instance_id = AgentInstanceId(sequence);
23241            queue_connection_event(
23242                &mut pending,
23243                NodeEventEnvelope {
23244                    sequence,
23245                    event: NodeEvent::TerminalFrame {
23246                        address,
23247                        frame: terminal_frame(sequence),
23248                    },
23249                },
23250                0,
23251            );
23252        }
23253        let mut discard_through = 0;
23254        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23255        assert!(pending.is_empty());
23256
23257        let replayed = NodeEventEnvelope {
23258            sequence: 8,
23259            event: NodeEvent::TerminalFrame {
23260                address: terminal_address(1),
23261                frame: terminal_frame(8),
23262            },
23263        };
23264        let advanced = NodeEventEnvelope {
23265            sequence: 9,
23266            event: NodeEvent::TerminalFrame {
23267                address: terminal_address(2),
23268                frame: terminal_frame(1),
23269            },
23270        };
23271        queue_connection_event_batch(
23272            &mut pending,
23273            &[replayed, advanced.clone()],
23274            discard_through,
23275        );
23276        assert_eq!(pending, vec![advanced]);
23277    }
23278
23279    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23280        ManagedSessionRecord {
23281            record_id: SessionRecordId::new(record_id).unwrap(),
23282            display_name: format!("{provider} record"),
23283            provider: agent(provider),
23284            mode: SessionMode::Pty,
23285            state: ManagedSessionState::Unavailable,
23286            workspace_id: WorkspaceId::new("primary").unwrap(),
23287            canonical_root: opaque_windows_path(
23288                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23289            ),
23290            provider_session: None,
23291            active_session: None,
23292            environment_profile: None,
23293            bundle: None,
23294            context_id: None,
23295            context: None,
23296            exported_context: None,
23297            task_binding: None,
23298            created_at_unix_ms: 1,
23299            updated_at_unix_ms: 1,
23300            last_error: None,
23301        }
23302    }
23303
23304    fn retention_test_record(
23305        workspace_id: &str,
23306        record_id: &str,
23307        state: ManagedSessionState,
23308        updated_at_unix_ms: u64,
23309    ) -> ManagedSessionRecord {
23310        ManagedSessionRecord {
23311            record_id: SessionRecordId::new(record_id).unwrap(),
23312            display_name: format!("{record_id} display"),
23313            provider: agent("claude"),
23314            mode: SessionMode::Pty,
23315            state,
23316            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23317            canonical_root: opaque_windows_path(
23318                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23319            ),
23320            provider_session: None,
23321            active_session: None,
23322            environment_profile: None,
23323            bundle: None,
23324            context_id: None,
23325            context: None,
23326            exported_context: None,
23327            task_binding: None,
23328            created_at_unix_ms: updated_at_unix_ms,
23329            updated_at_unix_ms,
23330            last_error: None,
23331        }
23332    }
23333
23334    /// Two workspaces of three `Unavailable` records each, staggered by
23335    /// age, plus one never-touchable decoy per non-`Unavailable` state in
23336    /// `workspace-a` (all as old as that workspace's oldest real record --
23337    /// if the sweep's state gate ever leaked, these would be the first
23338    /// thing it wrongly retired). Ages, oldest to newest:
23339    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
23340    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
23341    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23342        vec![
23343            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23344            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23345            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23346            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23347            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23348            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23349            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23350            retention_test_record(
23351                "workspace-a",
23352                "decoy-pending",
23353                ManagedSessionState::IdentityPending,
23354                now - 500_000,
23355            ),
23356            retention_test_record(
23357                "workspace-a",
23358                "decoy-dormant",
23359                ManagedSessionState::Dormant,
23360                now - 500_000,
23361            ),
23362        ]
23363    }
23364
23365    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23366        let root = temporary_workspace_root("session-record-retention");
23367        std::fs::create_dir_all(&root).unwrap();
23368        let catalog = active_registry().unwrap();
23369        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23370        let workspace_a =
23371            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23372        let workspace_b =
23373            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23374        let mut shared = NodeShared::new(
23375            handle,
23376            "fixture-token".to_owned(),
23377            NodeId::new("node-retention-test").unwrap(),
23378            vec![workspace_a, workspace_b],
23379            vec![agent("claude")],
23380        );
23381        shared.state_path = Some(root.join("node-state-v10.json"));
23382        let now = unix_time_ms();
23383        for record in retention_fixture_records(now) {
23384            shared.insert_record(record).unwrap();
23385        }
23386        shared
23387            .persist_state_locked()
23388            .expect("fixture-only, empty-history persist against a fresh temp state path");
23389        (shared, runtime, root, now)
23390    }
23391
23392    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23393        shared
23394            .history
23395            .lock()
23396            .unwrap_or_else(|poisoned| poisoned.into_inner())
23397            .events
23398            .iter()
23399            .filter_map(|envelope| match &envelope.event {
23400                NodeEvent::SessionRecordRemoved { record_id } => {
23401                    Some(record_id.as_str().to_owned())
23402                }
23403                _ => None,
23404            })
23405            .collect()
23406    }
23407
23408    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23409        shared
23410            .session_records
23411            .lock()
23412            .unwrap_or_else(|poisoned| poisoned.into_inner())
23413            .records
23414            .keys()
23415            .map(|record_id| record_id.as_str().to_owned())
23416            .collect()
23417    }
23418
23419    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23420        values.iter().map(|value| (*value).to_owned()).collect()
23421    }
23422
23423    #[tokio::test]
23424    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23425        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23426        shared.session_record_retention = SessionRecordRetentionConfig {
23427            age_ms: 0,
23428            keep_per_workspace: 1,
23429        };
23430        shared.retire_unavailable_session_records().await;
23431
23432        assert_eq!(
23433            retention_remaining_record_ids(&shared),
23434            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23435        );
23436        assert_eq!(
23437            retention_removed_record_ids(&shared),
23438            owned(&["a1", "a2", "b1", "b2"]),
23439        );
23440        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23441
23442        drop(shared);
23443        drop(runtime);
23444        std::fs::remove_dir_all(root).unwrap();
23445    }
23446
23447    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
23448    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
23449    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
23450    /// 200s old against a 250s threshold) even though it fails the keep-N
23451    /// test too, proving retirement needs BOTH, not either.
23452    #[tokio::test]
23453    async fn session_record_retention_both_set_retires_only_the_intersection() {
23454        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23455        shared.session_record_retention = SessionRecordRetentionConfig {
23456            age_ms: 250_000,
23457            keep_per_workspace: 1,
23458        };
23459        shared.retire_unavailable_session_records().await;
23460
23461        assert_eq!(
23462            retention_remaining_record_ids(&shared),
23463            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23464        );
23465        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23466        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23467
23468        drop(shared);
23469        drop(runtime);
23470        std::fs::remove_dir_all(root).unwrap();
23471    }
23472
23473    #[tokio::test]
23474    async fn session_record_retention_both_disabled_is_a_no_op() {
23475        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23476        shared.session_record_retention = SessionRecordRetentionConfig::default();
23477        shared.retire_unavailable_session_records().await;
23478
23479        assert_eq!(
23480            retention_remaining_record_ids(&shared),
23481            owned(&[
23482                "a1", "a2", "a3", "b1", "b2", "b3",
23483                "decoy-live", "decoy-pending", "decoy-dormant",
23484            ]),
23485        );
23486        assert!(retention_removed_record_ids(&shared).is_empty());
23487        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23488
23489        drop(shared);
23490        drop(runtime);
23491        std::fs::remove_dir_all(root).unwrap();
23492    }
23493
23494    #[test]
23495    fn session_record_context_export_selects_one_exact_history_identity() {
23496        let candidates = vec![
23497            HistoryCandidateSummary {
23498                id: "candidate-other".to_owned(),
23499                session_id_hint: "provider-other".to_owned(),
23500                modified_at_unix_ms: Some(1),
23501            },
23502            HistoryCandidateSummary {
23503                id: "candidate-exact".to_owned(),
23504                session_id_hint: "provider-exact".to_owned(),
23505                modified_at_unix_ms: Some(2),
23506            },
23507        ];
23508        assert_eq!(
23509            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23510            "candidate-exact",
23511        );
23512    }
23513
23514    #[test]
23515    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23516        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23517            id: id.to_owned(),
23518            session_id_hint: hint.to_owned(),
23519            modified_at_unix_ms: None,
23520        };
23521        let missing = select_history_candidate(
23522            &[candidate("candidate-other", "provider-other")],
23523            Some("provider-exact"),
23524        )
23525        .unwrap_err();
23526        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23527        let ambiguous = select_history_candidate(
23528            &[
23529                candidate("candidate-a", "provider-exact"),
23530                candidate("candidate-b", "provider-exact"),
23531            ],
23532            Some("provider-exact"),
23533        )
23534        .unwrap_err();
23535        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23536    }
23537
23538    /// The PTY-sidecar-observation shape (no fleet provider today, but
23539    /// capability-keyed rather than vendor-locked) never carries a verified
23540    /// `provider_session`; its record-bound export instead falls back to
23541    /// "exactly one discovered candidate" (see
23542    /// `export_context_pack_for_session_record_inner`). This is the same
23543    /// ambiguity refusal as the identity-filtered path, just without an
23544    /// identity to filter by.
23545    #[test]
23546    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23547        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23548            id: id.to_owned(),
23549            session_id_hint: hint.to_owned(),
23550            modified_at_unix_ms: None,
23551        };
23552        assert_eq!(
23553            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23554                .unwrap(),
23555            "candidate-sole",
23556        );
23557        let missing = select_history_candidate(&[], None).unwrap_err();
23558        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23559        let ambiguous = select_history_candidate(
23560            &[
23561                candidate("candidate-a", "sidecar-session-a"),
23562                candidate("candidate-b", "sidecar-session-b"),
23563            ],
23564            None,
23565        )
23566        .unwrap_err();
23567        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23568    }
23569
23570    #[test]
23571    fn session_record_context_export_revalidation_rejects_record_change() {
23572        let session = terminal_address(1);
23573        let identity = ProviderSessionIdentity {
23574            key: ProviderSessionKey::SessionId,
23575            id: "provider-exact".to_owned(),
23576            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23577        };
23578        let mut expected = record("claude", "record-context-export");
23579        expected.state = ManagedSessionState::Live;
23580        expected.provider_session = Some(identity.clone());
23581        expected.active_session = Some(session.clone());
23582        let binding = SessionBinding {
23583            workspace_id: session.workspace_id.clone(),
23584            generation: session.session.generation,
23585            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23586            pending_resume: None,
23587            record_id: Some(expected.record_id.clone()),
23588            managed_worktree_lease_id: None,
23589            environment_profile: None,
23590            bundle: None,
23591            context: None,
23592            materialization_id: None,
23593        };
23594        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23595        assert!(session_record_context_export_binding_is_exact(
23596            &expected,
23597            &expected,
23598            Some(&identity),
23599            &session,
23600            &binding,
23601            &expected.provider,
23602            &current_root,
23603            false,
23604        ));
23605        let mut changed = expected.clone();
23606        changed.updated_at_unix_ms += 1;
23607        assert!(!session_record_context_export_binding_is_exact(
23608            &expected,
23609            &changed,
23610            Some(&identity),
23611            &session,
23612            &binding,
23613            &expected.provider,
23614            &current_root,
23615            false,
23616        ));
23617        // A same-instant timestamp bump alone (state and active_session
23618        // otherwise unchanged) is not the clean-detachment transition
23619        // either, even with the reactive flag on: only the exact Live ->
23620        // Dormant + active_session cleared shape qualifies.
23621        assert!(!session_record_context_export_binding_is_exact(
23622            &expected,
23623            &changed,
23624            Some(&identity),
23625            &session,
23626            &binding,
23627            &expected.provider,
23628            &current_root,
23629            true,
23630        ));
23631        let mut detached = expected.clone();
23632        detached.state = ManagedSessionState::Dormant;
23633        detached.active_session = None;
23634        detached.updated_at_unix_ms += 1;
23635        assert!(!session_record_context_export_binding_is_exact(
23636            &expected,
23637            &detached,
23638            Some(&identity),
23639            &session,
23640            &binding,
23641            &expected.provider,
23642            &current_root,
23643            false,
23644        ));
23645        assert!(session_record_context_export_binding_is_exact(
23646            &expected,
23647            &detached,
23648            Some(&identity),
23649            &session,
23650            &binding,
23651            &expected.provider,
23652            &current_root,
23653            true,
23654        ));
23655    }
23656
23657    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
23658    /// for a record whose provider never carries a verified identity (the
23659    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
23660    /// declares one today): `identity` is `None` throughout, and the
23661    /// exactness check falls back to comparing `provider_session` itself,
23662    /// which must still be `None` on both sides.
23663    #[test]
23664    fn session_record_context_export_binding_is_exact_without_identity() {
23665        let session = terminal_address(1);
23666        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23667        expected.state = ManagedSessionState::Live;
23668        expected.active_session = Some(session.clone());
23669        let binding = SessionBinding {
23670            workspace_id: session.workspace_id.clone(),
23671            generation: session.session.generation,
23672            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23673            pending_resume: None,
23674            record_id: Some(expected.record_id.clone()),
23675            managed_worktree_lease_id: None,
23676            environment_profile: None,
23677            bundle: None,
23678            context: None,
23679            materialization_id: None,
23680        };
23681        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23682        assert!(session_record_context_export_binding_is_exact(
23683            &expected,
23684            &expected,
23685            None,
23686            &session,
23687            &binding,
23688            &expected.provider,
23689            &current_root,
23690            false,
23691        ));
23692        // A record that gained an identity between the initial lookup and
23693        // this recheck no longer matches the `None` this shape expects to
23694        // keep seeing -- treated the same as any other mid-export drift.
23695        let mut drifted = expected.clone();
23696        drifted.provider_session = Some(ProviderSessionIdentity {
23697            key: ProviderSessionKey::SessionId,
23698            id: "unexpected-identity".to_owned(),
23699            transcript_path: None,
23700        });
23701        assert!(!session_record_context_export_binding_is_exact(
23702            &expected,
23703            &drifted,
23704            None,
23705            &session,
23706            &binding,
23707            &expected.provider,
23708            &current_root,
23709            false,
23710        ));
23711    }
23712
23713    #[test]
23714    fn session_record_context_export_preflight_requires_live_usable_source() {
23715        let mut source = record("claude", "record-context-export-preflight");
23716        source.state = ManagedSessionState::Live;
23717        assert!(session_record_context_export_source_is_usable(
23718            &source,
23719            &SessionStatus::Running,
23720            false,
23721        ));
23722        assert!(session_record_context_export_source_is_usable(
23723            &source,
23724            &SessionStatus::Exited { exit_code: Some(0) },
23725            false,
23726        ));
23727
23728        source.state = ManagedSessionState::Dormant;
23729        assert!(!session_record_context_export_source_is_usable(
23730            &source,
23731            &SessionStatus::Running,
23732            false,
23733        ));
23734        // The reactive auto-export-at-exit path (allow_clean_detachment) is
23735        // the only caller that may treat a Dormant, cleanly-exited record as
23736        // still usable — the on-demand path above never does.
23737        assert!(session_record_context_export_source_is_usable(
23738            &source,
23739            &SessionStatus::Exited { exit_code: Some(0) },
23740            true,
23741        ));
23742        assert!(!session_record_context_export_source_is_usable(
23743            &source,
23744            &SessionStatus::Exited { exit_code: Some(0) },
23745            false,
23746        ));
23747        source.state = ManagedSessionState::Live;
23748        assert!(!session_record_context_export_source_is_usable(
23749            &source,
23750            &SessionStatus::Starting,
23751            false,
23752        ));
23753    }
23754
23755    #[test]
23756    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23757        let catalog = active_registry().unwrap();
23758        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23759        let workspace = WorkspaceConfig::new(
23760            WorkspaceId::new("primary").unwrap(),
23761            std::env::current_dir().unwrap(),
23762        )
23763        .unwrap();
23764        let shared = NodeShared::new(
23765            handle,
23766            "fixture-token".to_owned(),
23767            NodeId::new("node-context-export-conflict").unwrap(),
23768            vec![workspace],
23769            vec![agent("claude")],
23770        );
23771        let history = HistorySessionRecord {
23772            session_id: "provider-exact".to_owned(),
23773            title: None,
23774            cwd: None,
23775            model: None,
23776            message_count: 1,
23777            completed_turn_count: None,
23778            total_tokens: 0,
23779            messages: vec![gate4agent_types::HistoryMessageRecord {
23780                role: gate4agent_types::HistoryMessageRole::User,
23781                text: "bounded context".to_owned(),
23782            }],
23783        };
23784        let pack = NodeContextPack::export(
23785            ContextPackLineageReceipt {
23786                source_node_id: shared.node_id.clone(),
23787                source_session: terminal_address(1),
23788                source_provider: agent("claude"),
23789            },
23790            &history,
23791        )
23792        .unwrap();
23793        let context_id = pack.receipt().id.clone();
23794        let session = terminal_address(1);
23795        let identity = ProviderSessionIdentity {
23796            key: ProviderSessionKey::SessionId,
23797            id: history.session_id.clone(),
23798            transcript_path: None,
23799        };
23800        let mut expected = record("claude", "record-context-export-final-conflict");
23801        expected.state = ManagedSessionState::Live;
23802        expected.provider_session = Some(identity.clone());
23803        expected.active_session = Some(session.clone());
23804        let mut changed = expected.clone();
23805        changed.updated_at_unix_ms += 1;
23806        shared.insert_record(changed).unwrap();
23807
23808        let error = shared
23809            .commit_context_pack_for_session_record(
23810                &expected,
23811                Some(&identity),
23812                &session,
23813                "candidate-exact",
23814                &history,
23815                pack,
23816                false,
23817            )
23818            .unwrap_err();
23819
23820        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23821        assert!(shared
23822            .context_catalog
23823            .read()
23824            .unwrap_or_else(|poisoned| poisoned.into_inner())
23825            .get(&context_id)
23826            .is_none());
23827    }
23828
23829    #[tokio::test]
23830    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23831        let root = temporary_workspace_root("session-task-binding");
23832        std::fs::create_dir_all(&root).unwrap();
23833        let state_path = root.join("node-state.json");
23834        let catalog = active_registry().unwrap();
23835        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23836        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23837        let node_id = NodeId::new("node-1").unwrap();
23838        let mut shared = NodeShared::new(
23839            handle,
23840            "fixture-token".to_owned(),
23841            node_id.clone(),
23842            vec![workspace],
23843            vec![agent("claude")],
23844        );
23845        shared.state_path = Some(state_path.clone());
23846        let record = record("claude", "sr-task");
23847        let record_id = record.record_id.clone();
23848        shared.insert_record(record).unwrap();
23849        shared.persist_state().unwrap();
23850        shared
23851            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23852            .unwrap();
23853
23854        let minted = process_request_inner(
23855            &shared,
23856            77,
23857            ClientRole::Operator,
23858            NodeRequest::SetSessionTask {
23859                record_id: record_id.clone(),
23860                expected_revision: 0,
23861                target: SessionTaskTargetV1::New,
23862            },
23863        )
23864        .await
23865        .unwrap();
23866        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23867            panic!("session task mutation returned another response");
23868        };
23869        let minted_binding = minted.task_binding.clone().unwrap();
23870        let minted_task_id = minted_binding.task_id.clone().unwrap();
23871        assert_eq!(minted_binding.revision, 1);
23872        assert!(minted_task_id.as_str().starts_with("task-"));
23873        assert_eq!(minted_task_id.as_str().len(), 29);
23874
23875        let event_count = shared.current_sequence();
23876        let repeated = process_request_inner(
23877            &shared,
23878            77,
23879            ClientRole::Operator,
23880            NodeRequest::SetSessionTask {
23881                record_id: record_id.clone(),
23882                expected_revision: 1,
23883                target: SessionTaskTargetV1::Existing {
23884                    task_id: minted_task_id.clone(),
23885                },
23886            },
23887        )
23888        .await
23889        .unwrap();
23890        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23891        assert_eq!(shared.current_sequence(), event_count);
23892
23893        let stale = process_request_inner(
23894            &shared,
23895            77,
23896            ClientRole::Operator,
23897            NodeRequest::SetSessionTask {
23898                record_id: record_id.clone(),
23899                expected_revision: 0,
23900                target: SessionTaskTargetV1::Existing {
23901                    task_id: minted_task_id,
23902                },
23903            },
23904        )
23905        .await
23906        .unwrap_err();
23907        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23908
23909        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23910        let assigned = process_request_inner(
23911            &shared,
23912            77,
23913            ClientRole::Operator,
23914            NodeRequest::SetSessionTask {
23915                record_id: record_id.clone(),
23916                expected_revision: 1,
23917                target: SessionTaskTargetV1::Existing {
23918                    task_id: assigned_id.clone(),
23919                },
23920            },
23921        )
23922        .await
23923        .unwrap();
23924        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23925            panic!("session task assignment returned another response");
23926        };
23927        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23928        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23929
23930        let cleared = process_request_inner(
23931            &shared,
23932            77,
23933            ClientRole::Operator,
23934            NodeRequest::SetSessionTask {
23935                record_id: record_id.clone(),
23936                expected_revision: 2,
23937                target: SessionTaskTargetV1::Clear,
23938            },
23939        )
23940        .await
23941        .unwrap();
23942        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23943            panic!("session task clear returned another response");
23944        };
23945        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23946        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23947        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23948        assert_eq!(
23949            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23950            cleared.updated_at_unix_ms,
23951        );
23952
23953        let persisted = std::fs::read(&state_path).unwrap();
23954        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23955        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23956        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23957        std::fs::remove_dir_all(root).unwrap();
23958    }
23959
23960    #[test]
23961    fn session_task_capability_strips_snapshots_events_and_replies() {
23962        let mut bound = record("claude", "sr-task-projection");
23963        bound.updated_at_unix_ms = 2;
23964        bound.task_binding = Some(SessionTaskBindingV1 {
23965            revision: 1,
23966            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23967            changed_at_unix_ms: 2,
23968        });
23969        let request = NodeRequest::SetSessionTask {
23970            record_id: bound.record_id.clone(),
23971            expected_revision: 1,
23972            target: SessionTaskTargetV1::Clear,
23973        };
23974        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23975            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23976        }));
23977        assert!(!request_uses_unnegotiated_capability(
23978            &request,
23979            &baseline_capabilities().unwrap(),
23980        ));
23981        assert!(request_uses_unnegotiated_capability(&request, &[]));
23982
23983        let mut snapshot = terminal_test_shared().snapshot();
23984        snapshot.session_records.push(bound.clone());
23985        let mut reply = ResponseEnvelope {
23986            request_id: 1,
23987            result: Ok(NodeResponse::Resync {
23988                event_sequence: 1,
23989                oldest_available_sequence: 1,
23990                snapshot,
23991                events: vec![NodeEventEnvelope {
23992                    sequence: 1,
23993                    event: NodeEvent::SessionRecordUpserted {
23994                        record: bound.clone(),
23995                    },
23996                }],
23997            }),
23998        };
23999        project_response_without_session_task_binding(&mut reply);
24000        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24001            panic!("task projection changed the response kind");
24002        };
24003        assert!(snapshot.session_records[0].task_binding.is_none());
24004        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24005            panic!("task projection changed the event kind");
24006        };
24007        assert!(record.task_binding.is_none());
24008
24009        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24010            sequence: 2,
24011            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24012        });
24013        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24014            panic!("task event projection changed the event kind");
24015        };
24016        assert!(record.task_binding.is_none());
24017
24018        let mut direct = ResponseEnvelope {
24019            request_id: 2,
24020            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24021        };
24022        project_response_without_session_task_binding(&mut direct);
24023        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24024            panic!("task reply projection changed the response kind");
24025        };
24026        assert!(record.task_binding.is_none());
24027    }
24028
24029    #[test]
24030    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24031        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24032        let workspace = WorkspaceConfig::new(
24033            WorkspaceId::new("primary").unwrap(),
24034            std::env::current_dir().unwrap(),
24035        )
24036        .unwrap();
24037        let config = NodeServerConfig::new(
24038            DEFAULT_NODE_ENDPOINT,
24039            "fixture-token",
24040            NodeId::new("node-1").unwrap(),
24041            [workspace],
24042        )
24043        .unwrap();
24044        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24045    }
24046
24047    #[cfg(all(feature = "fixture", windows))]
24048    #[test]
24049    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24050        let root = temporary_workspace_root("provider-bundle-argv-hold");
24051        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24052        std::fs::create_dir_all(&root).unwrap();
24053        std::fs::create_dir_all(&outside).unwrap();
24054        let proof_path = root.join("provider.proof");
24055        let release_signal = root.join("release.signal");
24056        let config = NodeServerConfig::new(
24057            format!(
24058                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24059                std::process::id(),
24060            ),
24061            "fixture-token",
24062            NodeId::new("provider-bundle-hold-node").unwrap(),
24063            [WorkspaceConfig::new(
24064                WorkspaceId::new("primary").unwrap(),
24065                std::env::current_dir().unwrap(),
24066            )
24067            .unwrap()],
24068        )
24069        .unwrap()
24070        .with_spawn_profiles(SpawnProfileRegistry::new([
24071            crate::protocol::SpawnProfileDefaults {
24072                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24073                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24074                    .unwrap(),
24075                provider: agent("kimi"),
24076                mode: SessionMode::Pty,
24077                terminal_size: gate4agent_types::TerminalSize {
24078                    rows: 24,
24079                    columns: 80,
24080                },
24081                prompt: None,
24082                bundle_id: None,
24083                context_id: None,
24084                environment_profile_id: None,
24085            },
24086        ])
24087        .unwrap());
24088        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24089            config,
24090            agent("kimi"),
24091            proof_path.clone(),
24092            release_signal.clone(),
24093        )
24094        .unwrap();
24095        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24096
24097        let spec = NodeServer::provider_bundle_argv_fixture_spec(
24098            agent("kimi"),
24099            proof_path.clone(),
24100            Some(release_signal.clone()),
24101        )
24102        .unwrap();
24103        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24104        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24105        let release_wait = wrapper
24106            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24107            .unwrap();
24108        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24109        assert!(proof_write < release_wait);
24110        assert!(release_wait < fixture_ready);
24111        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24112        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24113        assert_eq!(
24114            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24115            &[
24116                proof_path.to_string_lossy().into_owned(),
24117                release_signal.to_string_lossy().into_owned(),
24118            ],
24119        );
24120
24121        let error = NodeServer::provider_bundle_argv_fixture_spec(
24122            agent("kimi"),
24123            proof_path,
24124            Some(outside.join("release.signal")),
24125        )
24126        .unwrap_err();
24127        assert!(matches!(
24128            error,
24129            NodeServerError::Registry(message)
24130                if message == "provider bundle argv release path must share the proof parent"
24131        ));
24132        drop(server);
24133        std::fs::remove_dir_all(root).unwrap();
24134        std::fs::remove_dir_all(outside).unwrap();
24135    }
24136
24137    #[cfg(feature = "fixture")]
24138    #[test]
24139    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24140        let root = temporary_workspace_root("context-fixture-registry");
24141        std::fs::create_dir_all(&root).unwrap();
24142        let proof_path = root.join("context.proof");
24143        let config = NodeServerConfig::new(
24144            format!(
24145                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24146                std::process::id(),
24147            ),
24148            "fixture-token",
24149            NodeId::new("context-fixture-node").unwrap(),
24150            [WorkspaceConfig::new(
24151                WorkspaceId::new("primary").unwrap(),
24152                std::env::current_dir().unwrap(),
24153            )
24154            .unwrap()],
24155        )
24156        .unwrap();
24157        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24158        let mut enabled = server
24159            .shared
24160            .enabled_providers
24161            .iter()
24162            .map(AgentId::as_str)
24163            .collect::<Vec<_>>();
24164        enabled.sort_unstable();
24165        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24166        for provider in &server.shared.enabled_providers {
24167            assert!(server
24168                .shared
24169                .provider_adapter_contracts
24170                .iter()
24171                .any(|contract| {
24172                    &contract.provider == provider
24173                        && contract.family == AdapterFamily::History
24174                }));
24175        }
24176        drop(server);
24177        std::fs::remove_dir_all(root).unwrap();
24178    }
24179
24180    #[cfg(feature = "fixture")]
24181    #[test]
24182    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24183        let root = temporary_workspace_root("monitoring-context-fixture");
24184        std::fs::create_dir_all(&root).unwrap();
24185        let proof_path = root.join("context.proof");
24186        let standard = NodeServer::context_pack_fixture_catalog(
24187            proof_path.clone(),
24188            None,
24189            false,
24190        ).unwrap();
24191        let monitoring = NodeServer::context_pack_fixture_catalog(
24192            proof_path.clone(),
24193            None,
24194            true,
24195        ).unwrap();
24196        let claude = AgentId::new("claude").unwrap();
24197        let standard_claude = standard.get(&claude).unwrap();
24198        let monitoring_claude = monitoring.get(&claude).unwrap();
24199        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
24200        // standard nor the monitoring fixture declares a hook adapter
24201        // anymore.
24202        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24203        assert_eq!(
24204            monitoring_claude.capabilities.adapters.history,
24205            standard_claude.capabilities.adapters.history,
24206        );
24207        assert_ne!(monitoring_claude.launch, standard_claude.launch);
24208        let codex = AgentId::new("codex").unwrap();
24209        let standard_codex = standard.get(&codex).unwrap();
24210        let monitoring_codex = monitoring.get(&codex).unwrap();
24211        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24212        assert_eq!(
24213            monitoring_codex.capabilities.adapters.history,
24214            standard_codex.capabilities.adapters.history,
24215        );
24216        assert_ne!(monitoring_codex.launch, standard_codex.launch);
24217        let codex_validation = monitoring_codex.launch.fixed_args.iter()
24218            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24219            .unwrap();
24220        for required in [
24221            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24222            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24223            "@('role', 'text')",
24224            "g4a-private-provider-session-canary",
24225            "private-provider-login-identity",
24226            "$rawProof",
24227            "$env:GATE4AGENT_HOOK_URL",
24228            "g4a-private-provider-session-canary",
24229        ] {
24230            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24231        }
24232        for provider in ["grok", "kimi"] {
24233            let provider = AgentId::new(provider).unwrap();
24234            assert_eq!(monitoring.get(&provider), standard.get(&provider));
24235        }
24236
24237        let config = NodeServerConfig::new(
24238            format!(
24239                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24240                std::process::id(),
24241            ),
24242            "fixture-token",
24243            NodeId::new("monitoring-context-fixture-node").unwrap(),
24244            [WorkspaceConfig::new(
24245                WorkspaceId::new("primary").unwrap(),
24246                std::env::current_dir().unwrap(),
24247            )
24248            .unwrap()],
24249        )
24250        .unwrap();
24251        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24252        assert!(server.shared.fixture_semantic_hook_policy);
24253        drop(server);
24254        std::fs::remove_dir_all(root).unwrap();
24255    }
24256
24257    #[cfg(feature = "fixture")]
24258    #[test]
24259    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24260        let root = temporary_workspace_root("kimi-context-only-fixture");
24261        std::fs::create_dir_all(&root).unwrap();
24262        let proof_path = root.join("context.proof");
24263        let standard = NodeServer::context_pack_fixture_catalog(
24264            proof_path.clone(),
24265            None,
24266            false,
24267        ).unwrap();
24268        let kimi = AgentId::new("kimi").unwrap();
24269        let context_only = NodeServer::context_pack_fixture_catalog(
24270            proof_path,
24271            Some(&kimi),
24272            false,
24273        )
24274        .unwrap();
24275
24276        let codex = AgentId::new("codex").unwrap();
24277        assert_eq!(
24278            standard.get(&codex).unwrap().launch.fixed_args,
24279            context_only.get(&codex).unwrap().launch.fixed_args,
24280        );
24281        let script = context_only
24282            .get(&kimi)
24283            .unwrap()
24284            .launch
24285            .fixed_args
24286            .iter()
24287            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24288            .expect("Kimi context-only validation script is missing");
24289        for required in [
24290            "$bundleArgs.Count -ne 0",
24291            "GATE4AGENT_CONTEXT_ROOT",
24292            "context-pack.json",
24293            "$entries.Count -ne 1",
24294            "g4a-context-pack-v1",
24295            "$document.PSObject.Properties['cwd']",
24296            "'claude', 'codex', 'grok', 'kimi'",
24297            "$hasUser",
24298            "$hasAssistant",
24299            "SHA256",
24300            "context-only",
24301        ] {
24302            assert!(script.contains(required), "missing validation: {required}");
24303        }
24304        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24305            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24306        }
24307        assert!(!standard
24308            .get(&kimi)
24309            .unwrap()
24310            .launch
24311            .fixed_args
24312            .iter()
24313            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24314
24315        std::fs::remove_dir_all(root).unwrap();
24316    }
24317
24318    #[cfg(all(feature = "fixture", windows))]
24319    #[tokio::test]
24320    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24321        let root = temporary_workspace_root("kimi-context-only-spawn");
24322        let workspace_root = root.join("workspace");
24323        std::fs::create_dir_all(&workspace_root).unwrap();
24324        let materialization_root = root.join("private-materializations");
24325        let proof_path = root.join("context.proof");
24326        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24327        let workspace_id = WorkspaceId::new("primary").unwrap();
24328        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24329        let endpoint = format!(
24330            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24331            std::process::id(),
24332            unix_time_ms(),
24333        );
24334        let spawn_profiles = SpawnProfileRegistry::new([
24335            crate::protocol::SpawnProfileDefaults {
24336                profile_id: profile_id.clone(),
24337                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24338                provider: agent("kimi"),
24339                mode: SessionMode::Pty,
24340                terminal_size: gate4agent_types::TerminalSize {
24341                    rows: 30,
24342                    columns: 120,
24343                },
24344                prompt: None,
24345                bundle_id: None,
24346                context_id: None,
24347                environment_profile_id: None,
24348            },
24349        ])
24350        .unwrap();
24351        let mut config = NodeServerConfig::new(
24352            &endpoint,
24353            "fixture-token",
24354            node_id.clone(),
24355            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24356        )
24357        .unwrap()
24358        .with_spawn_profiles(spawn_profiles)
24359        .with_session_environment_materialization(
24360            materialization_root,
24361            Arc::new(FixtureSecretResolver {
24362                deny: Arc::new(AtomicBool::new(false)),
24363            }),
24364        )
24365        .unwrap();
24366        config.fixture_raw_pty_runtime = true;
24367        let server = NodeServer::new_context_only_proof_fixture(
24368            config,
24369            agent("kimi"),
24370            proof_path.clone(),
24371        )
24372        .unwrap();
24373        let shared = Arc::clone(&server.shared);
24374        let shutdown = server.shutdown_handle();
24375
24376        let history = HistorySessionRecord {
24377            session_id: "context-only-source-history".to_owned(),
24378            title: None,
24379            cwd: None,
24380            model: None,
24381            message_count: 2,
24382            completed_turn_count: None,
24383            total_tokens: 0,
24384            messages: vec![
24385                gate4agent_types::HistoryMessageRecord {
24386                    role: gate4agent_types::HistoryMessageRole::User,
24387                    text: "continue the bounded parent run through native C2".to_owned(),
24388                },
24389                gate4agent_types::HistoryMessageRecord {
24390                    role: gate4agent_types::HistoryMessageRole::Assistant,
24391                    text: "the exact parent context is ready for the child run".to_owned(),
24392                },
24393            ],
24394        };
24395        let pack = NodeContextPack::export(
24396            ContextPackLineageReceipt {
24397                source_node_id: node_id.clone(),
24398                source_session: SessionAddress {
24399                    workspace_id: workspace_id.clone(),
24400                    session: SessionKey {
24401                        instance_id: AgentInstanceId(71),
24402                        generation: SessionGeneration(1),
24403                    },
24404                },
24405                source_provider: agent("codex"),
24406            },
24407            &history,
24408        )
24409        .unwrap();
24410        let expected_context_hash = digest(&SHA256, pack.bytes())
24411            .as_ref()
24412            .iter()
24413            .map(|byte| format!("{byte:02x}"))
24414            .collect::<String>();
24415        let context = shared
24416            .context_catalog
24417            .write()
24418            .unwrap_or_else(|poisoned| poisoned.into_inner())
24419            .insert(pack)
24420            .unwrap();
24421        let spec = SpawnSpec {
24422            target: crate::protocol::SpawnTarget {
24423                node_id,
24424                workspace_id: workspace_id.clone(),
24425                worktree_id: None,
24426            },
24427            profile_id,
24428            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24429                "target-kimi-r1",
24430            ).unwrap(),
24431            overrides: crate::protocol::SpawnOverrides {
24432                context_id: crate::protocol::SpawnOverride::Set {
24433                    value: context.id.clone(),
24434                },
24435                ..crate::protocol::SpawnOverrides::default()
24436            },
24437            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24438            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24439            required_capabilities: SpawnRequiredCapabilities::default(),
24440        };
24441        let resolved = match shared.resolve_spawn_spec(&spec) {
24442            Ok(resolved) => resolved,
24443            Err(error) => panic!(
24444                "Kimi context-only resolve rejected: exact_code={:?} message={}",
24445                error.code,
24446                error.message,
24447            ),
24448        };
24449        let resolved_context = match shared.resolve_context(&resolved) {
24450            Ok(Some(resolved_context)) => resolved_context,
24451            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24452            Err(error) => panic!(
24453                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24454                error.code,
24455                error.message,
24456            ),
24457        };
24458        assert_eq!(resolved_context, context);
24459        let runtime_policy = match shared
24460            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24461            .await
24462        {
24463            Ok(runtime_policy) => runtime_policy,
24464            Err(error) => panic!(
24465                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24466                error.code,
24467                error.message,
24468            ),
24469        };
24470        assert!(runtime_policy.raw_pty_lifecycle);
24471
24472        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24473        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24474            agent("kimi"),
24475            TransportKind::Pty,
24476            vec![EnvMutation {
24477                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24478                value: Some(OsString::from("must-remain-rejected")),
24479            }],
24480        )
24481        .unwrap();
24482        assert_eq!(
24483            launch_control
24484                .install_native_launch_environment_overlay(
24485                    AgentInstanceId(72),
24486                    generic_overlay,
24487                )
24488                .unwrap_err(),
24489            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24490        );
24491
24492        let diagnostic_address = SessionAddress {
24493            workspace_id: workspace_id.clone(),
24494            session: SessionKey {
24495                instance_id: AgentInstanceId(73),
24496                generation: SessionGeneration(1),
24497            },
24498        };
24499        let (diagnostic_overlay, diagnostic_materialization) = match shared
24500            .prepare_session_materialization(
24501                &diagnostic_address,
24502                &agent("kimi"),
24503                SessionMode::Pty,
24504                None,
24505                None,
24506                Some(&resolved_context),
24507                None,
24508            )
24509        {
24510            Ok(Some(prepared)) => prepared,
24511            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24512            Err(error) => panic!(
24513                "Kimi context-only materialization rejected: exact_code={:?} message={}",
24514                error.code,
24515                error.message,
24516            ),
24517        };
24518        let diagnostic_overlay = diagnostic_overlay
24519            .expect("Kimi context-only materialization omitted its environment overlay");
24520        assert!(matches!(
24521            &diagnostic_overlay,
24522            PreparedNativeLaunchOverlay::Environment(_),
24523        ));
24524        match shared.install_prepared_launch_overlay(
24525            diagnostic_address.session.instance_id,
24526            diagnostic_overlay,
24527        ) {
24528            Ok(None) => {}
24529            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24530            Err(error) => panic!(
24531                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24532                error.code,
24533                error.message,
24534            ),
24535        }
24536        assert!(launch_control.clear_native_instance_launch_overlay(
24537            diagnostic_address.session.instance_id,
24538        ));
24539        drop(diagnostic_materialization);
24540        assert!(shared
24541            .materializations
24542            .lock()
24543            .unwrap_or_else(|poisoned| poisoned.into_inner())
24544            .is_empty());
24545
24546        let node_task = tokio::spawn(server.run());
24547        let ready_client = timeout(Duration::from_secs(5), async {
24548            loop {
24549                match gate4agent_node_wire::LocalNodeClient::connect(
24550                    &endpoint,
24551                    &shared.node_id,
24552                    ClientRole::Observer,
24553                    "fixture-token",
24554                )
24555                .await
24556                {
24557                    Ok(client) => break client,
24558                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24559                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24560                }
24561            }
24562        })
24563        .await
24564        .expect("Kimi context-only Node did not become ready");
24565        assert_eq!(
24566            ready_client
24567                .hello()
24568                .snapshot
24569                .provider_runtime_statuses
24570                .iter()
24571                .find(|status| status.provider().as_str() == "kimi")
24572                .expect("Kimi runtime status is missing")
24573                .mode(),
24574            crate::protocol::ProviderRuntimeMode::RawPassthrough,
24575        );
24576        drop(ready_client);
24577
24578        let receipt = match shared.spawn_from_spec(spec).await {
24579            Ok(receipt) => receipt,
24580            Err(error) => panic!(
24581                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24582                error.code,
24583                error.message,
24584            ),
24585        };
24586        assert_eq!(receipt.provider, agent("kimi"));
24587        assert_eq!(receipt.context.as_ref(), Some(&context));
24588        assert!(receipt.context_binding_is_valid());
24589        timeout(Duration::from_secs(5), async {
24590            while !proof_path.is_file() {
24591                sleep(Duration::from_millis(10)).await;
24592            }
24593        })
24594        .await
24595        .expect("Kimi context-only fixture did not write its proof");
24596
24597        let proof = std::fs::read_to_string(&proof_path).unwrap();
24598        let lines = proof.lines().collect::<Vec<_>>();
24599        assert_eq!(lines.len(), 7);
24600        assert_eq!(lines[0], "context-only");
24601        assert!(Path::new(lines[1]).is_absolute());
24602        assert_ne!(
24603            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24604            std::fs::canonicalize(&workspace_root).unwrap(),
24605        );
24606        assert_eq!(
24607            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24608            std::fs::canonicalize(&workspace_root).unwrap(),
24609        );
24610        assert_eq!(lines[3], expected_context_hash);
24611        assert_eq!(lines[4], "g4a-context-pack-v1");
24612        assert_eq!(lines[5], "codex");
24613        assert_eq!(lines[6], "2");
24614        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24615            session.instance_id == receipt.session.session.instance_id
24616                && session.agent_id.as_str() == "kimi"
24617        }));
24618
24619        shutdown.request_shutdown().await.unwrap();
24620        timeout(Duration::from_secs(5), node_task)
24621            .await
24622            .unwrap()
24623            .unwrap()
24624            .unwrap();
24625        drop(shutdown);
24626        drop(shared);
24627        std::fs::remove_dir_all(root).unwrap();
24628    }
24629
24630    #[cfg(feature = "fixture")]
24631    #[test]
24632    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24633        let root = temporary_workspace_root("clean-exit-constructor");
24634        std::fs::create_dir_all(&root).unwrap();
24635        let config = construction_test_config("clean-exit-constructor")
24636            .with_state_path(root.join("node-state.json"))
24637            .unwrap();
24638        let server = NodeServer::new_clean_exit_fixture(
24639            config,
24640            root.clone(),
24641            root.join("started.marker"),
24642            root.join("release.signal"),
24643        )
24644        .unwrap();
24645        assert_eq!(
24646            server
24647                .shared
24648                .enabled_providers
24649                .iter()
24650                .map(AgentId::as_str)
24651                .collect::<Vec<_>>(),
24652            ["claude"],
24653        );
24654        drop(server);
24655        std::fs::remove_dir_all(root).unwrap();
24656    }
24657
24658    #[test]
24659    fn production_registry_provider_contract_manifest_is_exact() {
24660        let registry = active_registry().unwrap();
24661        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24662        assert_eq!(
24663            providers
24664                .iter()
24665                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24666                .collect::<Vec<_>>(),
24667            vec![
24668                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24669                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24670                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24671                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24672            ]
24673        );
24674        assert_eq!(
24675            adapters
24676                .iter()
24677                .map(|contract| (
24678                    contract.provider.as_str(),
24679                    contract.family,
24680                    contract.adapter_id.as_str(),
24681                    contract.revision.as_str(),
24682                ))
24683                .collect::<Vec<_>>(),
24684            vec![
24685                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24686                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24687                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24688                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24689                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24690                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24691                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24692                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24693                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24694                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24695                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24696                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24697                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24698                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24699                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24700                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24701                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24702                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24703                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24704                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24705                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24706                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24707                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24708            ]
24709        );
24710    }
24711
24712    fn construction_test_config(name: &str) -> NodeServerConfig {
24713        let workspace = WorkspaceConfig::new(
24714            WorkspaceId::new("primary").unwrap(),
24715            std::env::current_dir().unwrap(),
24716        )
24717        .unwrap();
24718        NodeServerConfig::new(
24719            format!(r"\\.\pipe\gate4agent-{name}"),
24720            "fixture-token",
24721            NodeId::new(name).unwrap(),
24722            [workspace],
24723        )
24724        .unwrap()
24725    }
24726
24727    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24728        let adapter = AdapterBinding::new(
24729            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24730            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24731            gate4agent_types::AdapterVerification::SyntheticFixture,
24732        )
24733        .unwrap();
24734        let agent_ids = ["claude", "codex", "kimi"]
24735            .into_iter()
24736            .map(|agent_id| AgentId::new(agent_id).unwrap())
24737            .collect::<Vec<_>>();
24738        let adapters = gate4agent_catalog::AdapterRegistry::new(
24739            [
24740                AdapterFamily::PtySemantic,
24741                AdapterFamily::Pipe,
24742                AdapterFamily::OneShot,
24743                AdapterFamily::Acp,
24744                AdapterFamily::Hook,
24745                AdapterFamily::ManagedHook,
24746                AdapterFamily::History,
24747                AdapterFamily::Resume,
24748                AdapterFamily::SessionOptions,
24749                AdapterFamily::CapabilityProbe,
24750            ]
24751            .into_iter()
24752            .map(|family| gate4agent_catalog::AdapterDescriptor {
24753                family,
24754                binding: adapter.clone(),
24755                agents: agent_ids.clone(),
24756            }),
24757        )
24758        .unwrap();
24759        let specs = agent_ids
24760            .iter()
24761            .map(|agent_id| {
24762                let agent_id = agent_id.as_str();
24763                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24764                spec.revision =
24765                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24766                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24767                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24768                    adapter: adapter.clone(),
24769                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24770                    launch_override: None,
24771                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24772                });
24773                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24774                    adapter: adapter.clone(),
24775                    launch_override: None,
24776                });
24777                spec.capabilities.adapters.hook = Some(adapter.clone());
24778                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24779                spec.capabilities.adapters.one_shot = Some(adapter.clone());
24780                spec.capabilities.adapters.history = Some(adapter.clone());
24781                spec.capabilities.adapters.resume = Some(adapter.clone());
24782                spec.capabilities.adapters.session_options = Some(adapter.clone());
24783                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24784                spec
24785            })
24786            .collect::<Vec<_>>();
24787        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24788    }
24789
24790    /// The real fleet's manifest is accepted by a real server construction.
24791    ///
24792    /// The count is asserted against `active_registry` itself rather than a
24793    /// literal, so this stays a test that construction succeeds for whatever
24794    /// the fleet is -- which is the property worth guarding -- instead of a
24795    /// second place the fleet size is written down and has to be kept in
24796    /// step with the first.
24797    #[test]
24798    fn node_server_construction_accepts_the_production_manifest() {
24799        let registry = active_registry().unwrap();
24800        let (providers, _) = provider_contract_manifest(&registry).unwrap();
24801        assert_eq!(providers.len(), registry.iter().count());
24802        assert!(!providers.is_empty());
24803        let server = NodeServer::new_with_registry(
24804            construction_test_config("manifest-production-bound"),
24805            registry,
24806        )
24807        .unwrap();
24808        drop(server);
24809    }
24810
24811    #[test]
24812    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24813        let registry = maximum_size_provider_manifest_registry();
24814        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24815        assert_eq!(providers.len(), 3);
24816        assert_eq!(adapters.len(), 30);
24817        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24818        let error = match NodeServer::new_with_registry(
24819            construction_test_config("manifest-handshake-overflow"),
24820            registry,
24821        ) {
24822            Ok(_) => panic!("oversized negotiated manifest was accepted"),
24823            Err(error) => error,
24824        };
24825        assert!(matches!(
24826            error,
24827            NodeServerError::ProviderContractManifest(message)
24828                if message.contains("authentication binding length")
24829                    && message.contains("8192-byte limit")
24830        ));
24831    }
24832
24833    #[test]
24834    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24835        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24836        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24837        let registry = AgentRegistry::new([spec]).unwrap();
24838        assert!(matches!(
24839            provider_contract_manifest(&registry),
24840            Err(NodeServerError::ProviderContractManifest(message))
24841                if message.contains("exceeds the 128-byte limit")
24842        ));
24843    }
24844
24845    #[test]
24846    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24847        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24848        spec.revision = "invalid revision".to_owned();
24849        let registry = AgentRegistry::new([spec]).unwrap();
24850        assert!(matches!(
24851            provider_contract_manifest(&registry),
24852            Err(NodeServerError::ProviderContractManifest(message))
24853                if message.contains("bounded lowercase ASCII")
24854        ));
24855    }
24856
24857    #[test]
24858    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24859        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24860        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24861        let conflicting_binding = builtin_registry()
24862            .get_by_id("codex")
24863            .unwrap()
24864            .capabilities
24865            .adapters
24866            .one_shot
24867            .clone()
24868            .unwrap();
24869        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24870        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24871        pipe.adapter = transport_binding;
24872        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24873        let registry = AgentRegistry::new([spec]).unwrap();
24874        assert!(matches!(
24875            provider_contract_manifest(&registry),
24876            Err(NodeServerError::ProviderContractManifest(message))
24877                if message.contains("conflicting OneShot adapter bindings")
24878        ));
24879    }
24880
24881    #[test]
24882    fn public_node_failures_expose_only_fixed_categories() {
24883        let provider = failure(
24884            NodeFailureCode::BackendOperationFailed,
24885            r"provider bearer-secret failed at C:\private\session.jsonl",
24886        );
24887        assert_eq!(provider.message, "backend-operation-failed");
24888        assert!(!provider.message.contains("bearer-secret"));
24889        assert!(!provider.message.contains(r"C:\private"));
24890        let persistence = persistence_failure(io::Error::new(
24891            io::ErrorKind::Other,
24892            r"state write failed at C:\private\state-v1.json",
24893        ));
24894        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24895        assert!(!persistence.message.contains(r"C:\private"));
24896
24897        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24898            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24899            assert_eq!(
24900                generic.to_string(),
24901                "node durable state failed: durable-state-load-failed",
24902            );
24903        }
24904    }
24905
24906    #[test]
24907    fn terminal_byte_sequences_are_bounded_before_dispatch() {
24908        let sequence = b"\x1b[1;5D".to_vec();
24909        assert_eq!(
24910            terminal_bytes_action(sequence.clone()).unwrap(),
24911            InputAction::TerminalBytes(sequence),
24912        );
24913        assert_eq!(
24914            terminal_bytes_action(Vec::new()).unwrap_err().code,
24915            NodeFailureCode::InvalidRequest,
24916        );
24917        assert_eq!(
24918            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24919                .unwrap_err()
24920                .code,
24921            NodeFailureCode::InvalidRequest,
24922        );
24923    }
24924
24925    #[test]
24926    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24927        let root = temporary_workspace_root("exclusive-state-owner");
24928        std::fs::create_dir_all(&root).unwrap();
24929        let workspace = WorkspaceConfig::new(
24930            WorkspaceId::new("primary").unwrap(),
24931            &root,
24932        )
24933        .unwrap();
24934        let state_path = root.join("state-v1.json");
24935        let endpoint = format!(
24936            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24937            std::process::id(),
24938            std::time::SystemTime::now()
24939                .duration_since(std::time::UNIX_EPOCH)
24940                .unwrap()
24941                .as_nanos(),
24942        );
24943        let config = NodeServerConfig::new(
24944            endpoint,
24945            "fixture-token",
24946            NodeId::new("node-1").unwrap(),
24947            [workspace],
24948        )
24949        .unwrap()
24950        .with_state_path(&state_path)
24951        .unwrap();
24952
24953        let first = NodeServer::new(config.clone()).unwrap();
24954        let error = match NodeServer::new(config.clone()) {
24955            Ok(_) => panic!("a second node server acquired the same durable state path"),
24956            Err(error) => error,
24957        };
24958        assert!(matches!(
24959            &error,
24960            NodeServerError::DurableState(error)
24961                if error.kind() == io::ErrorKind::WouldBlock
24962        ));
24963        assert_eq!(
24964            error.to_string(),
24965            "node durable state failed: durable-state-lock-failed",
24966        );
24967        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24968        drop(first);
24969        let second = NodeServer::new(config).unwrap();
24970        drop(second);
24971        std::fs::remove_dir_all(root).unwrap();
24972    }
24973
24974    #[test]
24975    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24976        let catalog = active_registry().unwrap();
24977        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24978        let workspace_id = WorkspaceId::new("primary").unwrap();
24979        let workspace = WorkspaceConfig::new(
24980            workspace_id.clone(),
24981            std::env::current_dir().unwrap(),
24982        )
24983        .unwrap();
24984        let shared = NodeShared::new(
24985            handle,
24986            "fixture-token".to_owned(),
24987            NodeId::new("node-1").unwrap(),
24988            vec![workspace.clone()],
24989            vec![agent("claude"), agent("codex"), agent("kimi")],
24990        );
24991        let instance_id = AgentInstanceId(17);
24992        let generation = SessionGeneration(3);
24993        assert!(shared.address_for(instance_id, generation).is_none());
24994        shared.bind_session(instance_id, workspace_id.clone(), generation);
24995        let address = SessionAddress {
24996            workspace_id,
24997            session: SessionKey { instance_id, generation },
24998        };
24999        assert_eq!(
25000            shared.workspace_root(&address.workspace_id).unwrap(),
25001            workspace.canonical_root,
25002        );
25003        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25004        shared.remove_binding(&address);
25005        assert!(shared.address_for(instance_id, generation).is_none());
25006    }
25007
25008    #[test]
25009    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25010        let address = SessionAddress {
25011            workspace_id: WorkspaceId::new("primary").unwrap(),
25012            session: SessionKey {
25013                instance_id: AgentInstanceId(7),
25014                generation: SessionGeneration(2),
25015            },
25016        };
25017        let record_id = SessionRecordId::new("record-a").unwrap();
25018        let size = gate4agent_types::TerminalSize {
25019            rows: 24,
25020            columns: 80,
25021        };
25022        let address_requests = [
25023            NodeRequest::Resume {
25024                session: address.clone(),
25025                terminal_size: size,
25026                initial_prompt: None,
25027            },
25028            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25029            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25030            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25031            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25032            NodeRequest::TerminalControl {
25033                session: address.clone(),
25034                control: TerminalControl::Enter,
25035            },
25036            NodeRequest::Resize { session: address.clone(), size },
25037            NodeRequest::Interrupt { session: address.clone() },
25038            NodeRequest::Stop { session: address.clone(), force: false },
25039            NodeRequest::Remove { session: address },
25040        ];
25041        for request in &address_requests {
25042            assert!(request_requires_open_provider_ids_with(
25043                request,
25044                |_| Some(agent("third-party-agent")),
25045                |_| Some(agent("claude")),
25046            ));
25047            assert!(!request_requires_open_provider_ids_with(
25048                request,
25049                |_| Some(agent("claude")),
25050                |_| Some(agent("claude")),
25051            ));
25052            assert!(request_requires_open_provider_ids_with(
25053                request,
25054                |_| None,
25055                |_| Some(agent("claude")),
25056            ));
25057        }
25058
25059        let record_requests = [
25060            NodeRequest::RenameSessionRecord {
25061                record_id: record_id.clone(),
25062                display_name: "renamed".to_owned(),
25063            },
25064            NodeRequest::ResumeSessionRecord {
25065                record_id: record_id.clone(),
25066                terminal_size: size,
25067                initial_prompt: None,
25068            },
25069            NodeRequest::ForgetSessionRecord { record_id },
25070        ];
25071        for request in &record_requests {
25072            assert!(request_requires_open_provider_ids_with(
25073                request,
25074                |_| Some(agent("claude")),
25075                |_| Some(agent("grok")),
25076            ));
25077            assert!(!request_requires_open_provider_ids_with(
25078                request,
25079                |_| Some(agent("claude")),
25080                |_| Some(agent("kimi")),
25081            ));
25082            assert!(request_requires_open_provider_ids_with(
25083                request,
25084                |_| Some(agent("claude")),
25085                |_| None,
25086            ));
25087        }
25088
25089        assert!(request_requires_open_provider_ids_with(
25090            &NodeRequest::Spawn {
25091                workspace_id: WorkspaceId::new("primary").unwrap(),
25092                provider: agent("third-party-agent"),
25093                mode: SessionMode::Pty,
25094                terminal_size: size,
25095                initial_prompt: None,
25096            },
25097            |_| None,
25098            |_| None,
25099        ));
25100        assert!(!request_requires_open_provider_ids_with(
25101            &NodeRequest::Snapshot,
25102            |_| None,
25103            |_| None,
25104        ));
25105        assert!(request_requires_open_provider_ids_with(
25106            &NodeRequest::ForgetContextPack {
25107                context_id: SpawnContextId::new("ctx-forget").unwrap(),
25108            },
25109            |_| None,
25110            |_| None,
25111        ));
25112    }
25113
25114    #[test]
25115    fn legacy_projection_filters_open_record_replies_and_event_history() {
25116        let catalog = active_registry().unwrap();
25117        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25118        let workspace = WorkspaceConfig::new(
25119            WorkspaceId::new("primary").unwrap(),
25120            std::env::current_dir().unwrap(),
25121        )
25122        .unwrap();
25123        let shared = NodeShared::new(
25124            handle,
25125            "fixture-token".to_owned(),
25126            NodeId::new("node-1").unwrap(),
25127            vec![workspace],
25128            vec![agent("claude"), agent("grok")],
25129        );
25130        let legacy = record("claude", "legacy-record");
25131        let open = record("grok", "open-record");
25132        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25133            record: legacy.clone(),
25134        });
25135        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25136            record: open.clone(),
25137        });
25138        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25139            record_id: legacy.record_id.clone(),
25140        });
25141        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25142            record_id: open.record_id.clone(),
25143        });
25144        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25145        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25146        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25147        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25148
25149        let mut record_reply = ResponseEnvelope {
25150            request_id: 1,
25151            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25152        };
25153        project_response_legacy_provider_ids(&shared, &mut record_reply);
25154        assert!(matches!(
25155            record_reply.result,
25156            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25157        ));
25158
25159        let mut resync = ResponseEnvelope {
25160            request_id: 2,
25161            result: Ok(NodeResponse::Resync {
25162                event_sequence: open_removed.sequence,
25163                oldest_available_sequence: 1,
25164                snapshot: shared.snapshot(),
25165                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25166            }),
25167        };
25168        project_response_legacy_provider_ids(&shared, &mut resync);
25169        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25170            panic!("legacy projection changed the resync response kind");
25171        };
25172        assert!(snapshot.session_records.iter().all(|item| {
25173            provider_id_is_legacy(&item.provider)
25174        }));
25175        assert_eq!(events.len(), 2);
25176        assert!(events.iter().all(|event| matches!(
25177            &event.event,
25178            NodeEvent::SessionRecordUpserted { record }
25179                if provider_id_is_legacy(&record.provider)
25180        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25181    }
25182
25183    #[test]
25184    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25185        let catalog = active_registry().unwrap();
25186        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25187        let workspace_id = WorkspaceId::new("primary").unwrap();
25188        let workspace = WorkspaceConfig::new(
25189            workspace_id.clone(),
25190            std::env::current_dir().unwrap(),
25191        )
25192        .unwrap();
25193        let shared = NodeShared::new(
25194            handle,
25195            "fixture-token".to_owned(),
25196            NodeId::new("node-1").unwrap(),
25197            vec![workspace],
25198            vec![agent("claude")],
25199        );
25200        for index in 0..CONTROL_SESSIONS_MAX {
25201            shared.bind_session(
25202                AgentInstanceId((index + 1) as u64),
25203                workspace_id.clone(),
25204                SessionGeneration(1),
25205            );
25206        }
25207        let error = shared.ensure_binding_capacity().unwrap_err();
25208        assert_eq!(error.code, NodeFailureCode::BackendBusy);
25209    }
25210
25211    #[tokio::test]
25212    async fn provider_spawn_admission_rejects_before_session_mutation() {
25213        let catalog = active_registry().unwrap();
25214        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25215        let workspace = WorkspaceConfig::new(
25216            WorkspaceId::new("primary").unwrap(),
25217            std::env::current_dir().unwrap(),
25218        )
25219        .unwrap();
25220        let mut shared = NodeShared::new(
25221            handle,
25222            "fixture-token".to_owned(),
25223            NodeId::new("node-provider-admission").unwrap(),
25224            vec![workspace],
25225            vec![agent("claude")],
25226        );
25227        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25228            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25229        ])
25230        .unwrap();
25231        let terminal_size = gate4agent_types::TerminalSize {
25232            rows: 24,
25233            columns: 80,
25234        };
25235
25236        let unavailable = shared
25237            .spawn_session(
25238                WorkspaceId::new("primary").unwrap(),
25239                agent("claude"),
25240                SessionMode::Pty,
25241                terminal_size,
25242                None,
25243            )
25244            .await
25245            .unwrap_err();
25246        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25247        assert_eq!(unavailable.message, "backend-operation-failed");
25248
25249        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25250            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25251                agent("claude"),
25252                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25253            ),
25254        ])
25255        .unwrap();
25256        let semantic = shared
25257            .spawn_session(
25258                WorkspaceId::new("primary").unwrap(),
25259                agent("claude"),
25260                SessionMode::Inline,
25261                terminal_size,
25262                None,
25263            )
25264            .await
25265            .unwrap_err();
25266        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25267        assert_eq!(semantic.message, "unsupported-capability");
25268
25269        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25270        assert!(shared
25271            .session_bindings
25272            .lock()
25273            .unwrap_or_else(|poisoned| poisoned.into_inner())
25274            .is_empty());
25275        assert!(shared
25276            .session_records
25277            .lock()
25278            .unwrap_or_else(|poisoned| poisoned.into_inner())
25279            .records
25280            .is_empty());
25281        assert!(shared.handle.snapshot().sessions.is_empty());
25282    }
25283
25284    #[tokio::test]
25285    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25286        let catalog = active_registry().unwrap();
25287        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25288        let workspace_id = WorkspaceId::new("primary").unwrap();
25289        let workspace = WorkspaceConfig::new(
25290            workspace_id.clone(),
25291            std::env::current_dir().unwrap(),
25292        )
25293        .unwrap();
25294        let shared = NodeShared::new(
25295            handle,
25296            "fixture-token".to_owned(),
25297            NodeId::new("node-raw-spawn-record").unwrap(),
25298            vec![workspace],
25299            vec![agent("claude")],
25300        );
25301        let address = SessionAddress {
25302            workspace_id,
25303            session: SessionKey {
25304                instance_id: AgentInstanceId(71),
25305                generation: SessionGeneration::default(),
25306            },
25307        };
25308
25309        let record_id = shared
25310            .bind_spawn_session(
25311                &address,
25312                agent("claude"),
25313                SessionMode::Pty,
25314                ProviderRuntimePolicy::raw_pty(),
25315                SpawnRecordPolicy::Always,
25316                None,
25317            )
25318            .unwrap()
25319            .unwrap();
25320        shared
25321            .handle
25322            .dispatch(shared.prepare_command(ControlCommand::Register {
25323                instance_id: address.session.instance_id,
25324                agent_id: agent("claude"),
25325                transport: TransportKind::Pty,
25326            }))
25327            .unwrap();
25328        runtime.tick().await;
25329
25330        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25331            session.instance_id == address.session.instance_id
25332                && session.generation == address.session.generation
25333        }));
25334        let record = shared.record(&record_id).unwrap();
25335        assert_eq!(record.display_name, "claude #1");
25336        assert_eq!(record.state, ManagedSessionState::Live);
25337        assert_eq!(record.active_session.as_ref(), Some(&address));
25338        assert!(record.provider_session.is_none());
25339        let binding_record_id = shared
25340            .session_bindings
25341            .lock()
25342            .unwrap_or_else(|poisoned| poisoned.into_inner())
25343            .get(&address.session.instance_id)
25344            .and_then(|binding| binding.record_id.clone());
25345        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25346
25347        let renamed = shared
25348            .rename_session_record(&record_id, "raw review".to_owned())
25349            .unwrap();
25350        assert_eq!(renamed.record_id, record_id);
25351        assert_eq!(renamed.display_name, "raw review");
25352        shared.reconcile_managed_record(
25353            &record_id,
25354            &address,
25355            &ControlEvent {
25356                sequence: 1,
25357                command_id: None,
25358                instance_id: address.session.instance_id,
25359                generation: address.session.generation,
25360                event: ControlEventKind::Running { process_id: None },
25361            },
25362            false,
25363            false,
25364        );
25365        let running = shared.record(&record_id).unwrap();
25366        assert_eq!(running.state, ManagedSessionState::Live);
25367        assert_eq!(running.active_session.as_ref(), Some(&address));
25368        assert_eq!(running.display_name, "raw review");
25369
25370        shared.reconcile_managed_record(
25371            &record_id,
25372            &address,
25373            &ControlEvent {
25374                sequence: 2,
25375                command_id: None,
25376                instance_id: address.session.instance_id,
25377                generation: address.session.generation,
25378                event: ControlEventKind::Exited {
25379                    exit_code: Some(0),
25380                    forced: false,
25381                },
25382            },
25383            false,
25384            false,
25385        );
25386        let detached = shared.record(&record_id).unwrap();
25387        assert_eq!(detached.state, ManagedSessionState::Unavailable);
25388        assert!(detached.active_session.is_none());
25389        assert_eq!(detached.display_name, "raw review");
25390        let renamed_detached = shared
25391            .rename_session_record(&record_id, "raw review detached".to_owned())
25392            .unwrap();
25393        assert_eq!(renamed_detached.record_id, record_id);
25394        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25395        assert!(renamed_detached.active_session.is_none());
25396        assert_eq!(renamed_detached.display_name, "raw review detached");
25397        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25398    }
25399
25400    #[test]
25401    fn legacy_raw_spawn_binding_remains_untracked() {
25402        let catalog = active_registry().unwrap();
25403        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25404        let workspace_id = WorkspaceId::new("primary").unwrap();
25405        let workspace = WorkspaceConfig::new(
25406            workspace_id.clone(),
25407            std::env::current_dir().unwrap(),
25408        )
25409        .unwrap();
25410        let shared = NodeShared::new(
25411            handle,
25412            "fixture-token".to_owned(),
25413            NodeId::new("node-legacy-raw-spawn").unwrap(),
25414            vec![workspace],
25415            vec![agent("claude")],
25416        );
25417        let address = SessionAddress {
25418            workspace_id,
25419            session: SessionKey {
25420                instance_id: AgentInstanceId(72),
25421                generation: SessionGeneration::default(),
25422            },
25423        };
25424
25425        let record_id = shared
25426            .bind_spawn_session(
25427                &address,
25428                agent("claude"),
25429                SessionMode::Pty,
25430                ProviderRuntimePolicy::raw_pty(),
25431                SpawnRecordPolicy::ProviderIdentityOnly,
25432                None,
25433            )
25434            .unwrap();
25435
25436        assert!(record_id.is_none());
25437        let binding_record_id = shared
25438            .session_bindings
25439            .lock()
25440            .unwrap_or_else(|poisoned| poisoned.into_inner())
25441            .get(&address.session.instance_id)
25442            .and_then(|binding| binding.record_id.clone());
25443        assert!(binding_record_id.is_none());
25444        assert!(shared.snapshot().session_records.is_empty());
25445    }
25446
25447    #[tokio::test]
25448    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25449        let catalog = active_registry().unwrap();
25450        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25451        let workspace_id = WorkspaceId::new("primary").unwrap();
25452        let workspace = WorkspaceConfig::new(
25453            workspace_id.clone(),
25454            std::env::current_dir().unwrap(),
25455        )
25456        .unwrap();
25457        let mut shared = NodeShared::new(
25458            handle,
25459            "fixture-token".to_owned(),
25460            NodeId::new("node-raw-semantic-gate").unwrap(),
25461            vec![workspace],
25462            vec![agent("claude")],
25463        );
25464        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25465            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25466        ])
25467        .unwrap();
25468        shared
25469            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25470            .unwrap();
25471        let address = SessionAddress {
25472            workspace_id,
25473            session: SessionKey {
25474                instance_id: AgentInstanceId(72),
25475                generation: SessionGeneration::default(),
25476            },
25477        };
25478        shared.bind_session_with_policy(
25479            &address,
25480            ProviderRuntimePolicy::raw_pty(),
25481            None,
25482        );
25483        shared
25484            .handle
25485            .dispatch(shared.prepare_command(ControlCommand::Register {
25486                instance_id: address.session.instance_id,
25487                agent_id: agent("claude"),
25488                transport: TransportKind::Pty,
25489            }))
25490            .unwrap();
25491        runtime.tick().await;
25492        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25493        let revision_before = shared.handle.snapshot().revision;
25494        let history_sequence_before = shared.current_sequence();
25495        let terminal_size = gate4agent_types::TerminalSize {
25496            rows: 24,
25497            columns: 80,
25498        };
25499
25500        for request in [
25501            NodeRequest::Prompt {
25502                session: address.clone(),
25503                text: "prompt".to_owned(),
25504            },
25505            NodeRequest::Paste {
25506                session: address.clone(),
25507                text: "paste".to_owned(),
25508            },
25509            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
25510            // the same semantic capabilities as `Prompt`/`Paste`. A bare
25511            // (promptless) resume is a raw PTY relaunch and IS admitted under
25512            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
25513            // in `provider_runtime.rs`; it is deliberately not exercised here.
25514            NodeRequest::Resume {
25515                session: address.clone(),
25516                terminal_size,
25517                initial_prompt: Some("resume".to_owned()),
25518            },
25519        ] {
25520            let error = process_request_inner(
25521                &shared,
25522                77,
25523                ClientRole::Operator,
25524                request,
25525            )
25526            .await
25527            .unwrap_err();
25528            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25529        }
25530
25531        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25532        assert_eq!(shared.handle.snapshot().revision, revision_before);
25533        assert_eq!(shared.current_sequence(), history_sequence_before);
25534        let bindings = shared
25535            .session_bindings
25536            .lock()
25537            .unwrap_or_else(|poisoned| poisoned.into_inner());
25538        let binding = bindings.get(&address.session.instance_id).unwrap();
25539        assert_eq!(binding.generation, address.session.generation);
25540        assert!(binding.pending_resume.is_none());
25541        assert!(binding.record_id.is_none());
25542    }
25543
25544    #[tokio::test]
25545    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25546        let catalog = active_registry().unwrap();
25547        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25548        let primary = WorkspaceConfig::new(
25549            WorkspaceId::new("primary").unwrap(),
25550            std::env::current_dir().unwrap(),
25551        )
25552        .unwrap();
25553        let shared = NodeShared::new(
25554            handle,
25555            "fixture-token".to_owned(),
25556            NodeId::new("node-1").unwrap(),
25557            vec![primary],
25558            vec![agent("claude")],
25559        );
25560        shared
25561            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25562            .unwrap();
25563        let before = shared.snapshot();
25564        let error = process_request_inner(
25565            &shared,
25566            77,
25567            ClientRole::Operator,
25568            NodeRequest::RegisterWorkspace {
25569                workspace_id: WorkspaceId::new("foreign").unwrap(),
25570                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25571            },
25572        )
25573        .await
25574        .unwrap_err();
25575        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25576        assert_eq!(error.message, "invalid-request");
25577        assert_eq!(shared.snapshot().workspaces, before.workspaces);
25578    }
25579
25580    #[tokio::test]
25581    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25582        let catalog = active_registry().unwrap();
25583        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25584        let primary_id = WorkspaceId::new("primary").unwrap();
25585        let primary = WorkspaceConfig::new(
25586            primary_id.clone(),
25587            std::env::current_dir().unwrap(),
25588        )
25589        .unwrap();
25590        let shared = NodeShared::new(
25591            handle,
25592            "fixture-token".to_owned(),
25593            NodeId::new("node-1").unwrap(),
25594            vec![primary.clone()],
25595            vec![agent("claude")],
25596        );
25597        let secondary_id = WorkspaceId::new("secondary").unwrap();
25598        let secondary = shared
25599            .register_workspace(
25600                secondary_id.clone(),
25601                std::env::temp_dir().to_string_lossy().into_owned(),
25602            )
25603            .await
25604            .unwrap();
25605        assert_eq!(secondary.workspace_id, secondary_id);
25606        assert!(secondary.sessions.is_empty());
25607        assert_eq!(shared.snapshot().workspaces.len(), 2);
25608
25609        let duplicate_id = shared
25610            .register_workspace(
25611                secondary_id.clone(),
25612                primary.canonical_root().to_owned(),
25613            )
25614            .await
25615            .unwrap_err();
25616        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25617        let duplicate_root = shared
25618            .register_workspace(
25619                WorkspaceId::new("duplicate-root").unwrap(),
25620                windows_path_text(&secondary.canonical_root).to_owned(),
25621            )
25622            .await
25623            .unwrap_err();
25624        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25625
25626        let address = SessionAddress {
25627            workspace_id: secondary_id.clone(),
25628            session: SessionKey {
25629                instance_id: AgentInstanceId(41),
25630                generation: SessionGeneration(1),
25631            },
25632        };
25633        shared.bind_session(
25634            address.session.instance_id,
25635            secondary_id.clone(),
25636            address.session.generation,
25637        );
25638        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25639        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25640        shared.remove_binding(&address);
25641        shared.unregister_workspace(&secondary_id).unwrap();
25642        assert_eq!(shared.snapshot().workspaces.len(), 1);
25643
25644        let last = shared.unregister_workspace(&primary_id).unwrap_err();
25645        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25646        let history = shared
25647            .history
25648            .lock()
25649            .unwrap_or_else(|poisoned| poisoned.into_inner());
25650        assert!(matches!(
25651            &history.events[0],
25652            NodeEventEnvelope {
25653                sequence: 1,
25654                event: NodeEvent::WorkspaceAdded { workspace },
25655            } if workspace.workspace_id == secondary_id
25656        ));
25657        assert!(matches!(
25658            &history.events[1],
25659            NodeEventEnvelope {
25660                sequence: 2,
25661                event: NodeEvent::WorkspaceRemoved { workspace_id },
25662            } if workspace_id == &secondary_id
25663        ));
25664    }
25665
25666    #[tokio::test]
25667    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25668        let secondary_root = temporary_workspace_root("provider-session-reference-index");
25669        std::fs::create_dir_all(&secondary_root).unwrap();
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 secondary_id = WorkspaceId::new("secondary").unwrap();
25674        let shared = NodeShared::new(
25675            handle,
25676            "fixture-token".to_owned(),
25677            NodeId::new("node-session-reference-index").unwrap(),
25678            vec![
25679                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25680                    .unwrap(),
25681                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25682            ],
25683            vec![agent("claude"), agent("codex")],
25684        );
25685        let identity = ProviderSessionIdentity {
25686            key: gate4agent_types::ProviderSessionKey::SessionId,
25687            id: "provider-session-42".to_owned(),
25688            transcript_path: None,
25689        };
25690        let index_request = |workspace_id: WorkspaceId,
25691                             provider: AgentId,
25692                             identity: ProviderSessionIdentity,
25693                             display_name: &str| {
25694            NodeRequest::IndexProviderSession {
25695                workspace_id,
25696                provider,
25697                identity,
25698                display_name: display_name.to_owned(),
25699            }
25700        };
25701        let request = index_request(
25702            primary_id.clone(),
25703            agent("claude"),
25704            identity.clone(),
25705            "release shepherd",
25706        );
25707        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25708            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25709        }));
25710        let observer = process_request_inner(
25711            &shared,
25712            77,
25713            ClientRole::Observer,
25714            request.clone(),
25715        )
25716        .await
25717        .unwrap_err();
25718        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25719        let uncontrolled = process_request_inner(
25720            &shared,
25721            77,
25722            ClientRole::Operator,
25723            request.clone(),
25724        )
25725        .await
25726        .unwrap_err();
25727        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25728        shared
25729            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25730            .unwrap();
25731
25732        let indexed = process_request_inner(
25733            &shared,
25734            77,
25735            ClientRole::Operator,
25736            request.clone(),
25737        )
25738        .await
25739        .unwrap();
25740        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25741            panic!("provider session reference index returned another response");
25742        };
25743        assert_eq!(record.state, ManagedSessionState::Dormant);
25744        assert_eq!(record.mode, SessionMode::Pty);
25745        assert_eq!(record.workspace_id, primary_id);
25746        assert_eq!(record.provider_session.as_ref(), Some(&identity));
25747        assert!(record.active_session.is_none());
25748        assert!(record.environment_profile.is_none());
25749        assert!(record.bundle.is_none());
25750        assert!(record.context.is_none());
25751
25752        let repeated = process_request_inner(
25753            &shared,
25754            77,
25755            ClientRole::Operator,
25756            index_request(
25757                primary_id.clone(),
25758                agent("claude"),
25759                identity.clone(),
25760                "ignored replacement",
25761            ),
25762        )
25763        .await
25764        .unwrap();
25765        assert!(matches!(
25766            repeated,
25767            NodeResponse::ProviderSessionIndexed { record: ref existing }
25768                if existing.record_id == record.record_id
25769                    && existing.display_name == "release shepherd"
25770        ));
25771        let conflicting_workspace = process_request_inner(
25772            &shared,
25773            77,
25774            ClientRole::Operator,
25775            index_request(
25776                secondary_id,
25777                agent("claude"),
25778                identity.clone(),
25779                "release shepherd",
25780            ),
25781        )
25782        .await
25783        .unwrap_err();
25784        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25785        let conflicting_provider = process_request_inner(
25786            &shared,
25787            77,
25788            ClientRole::Operator,
25789            index_request(
25790                primary_id.clone(),
25791                agent("codex"),
25792                identity.clone(),
25793                "release shepherd",
25794            ),
25795        )
25796        .await
25797        .unwrap_err();
25798        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25799        let transcript_rejected = process_request_inner(
25800            &shared,
25801            77,
25802            ClientRole::Operator,
25803            index_request(
25804                primary_id,
25805                agent("claude"),
25806                ProviderSessionIdentity {
25807                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25808                    ..identity
25809                },
25810                "release shepherd",
25811            ),
25812        )
25813        .await
25814        .unwrap_err();
25815        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25816        let upserts = shared
25817            .history
25818            .lock()
25819            .unwrap_or_else(|poisoned| poisoned.into_inner())
25820            .events
25821            .iter()
25822            .filter(|event| matches!(
25823                &event.event,
25824                NodeEvent::SessionRecordUpserted { record: published }
25825                    if published.record_id == record.record_id
25826            ))
25827            .count();
25828        assert_eq!(upserts, 1);
25829        std::fs::remove_dir_all(secondary_root).unwrap();
25830    }
25831
25832    #[tokio::test]
25833    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25834        let fixture_root = temporary_workspace_root("standalone-independent");
25835        let target = fixture_root.join("independent");
25836        std::fs::create_dir_all(&fixture_root).unwrap();
25837        let catalog = active_registry().unwrap();
25838        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25839        let selected = WorkspaceConfig::new(
25840            WorkspaceId::new("primary").unwrap(),
25841            std::env::current_dir().unwrap(),
25842        )
25843        .unwrap();
25844        let selected_root = selected.canonical_root().to_owned();
25845        let shared = NodeShared::new(
25846            handle,
25847            "fixture-token".to_owned(),
25848            NodeId::new("node-standalone").unwrap(),
25849            vec![selected],
25850            vec![agent("claude")],
25851        );
25852        let workspace_id = WorkspaceId::new("independent").unwrap();
25853        let request = NodeRequest::CreateStandaloneWorkspace {
25854            workspace_id: workspace_id.clone(),
25855            root: opaque_windows_path(target.to_string_lossy().into_owned()),
25856            initial_branch: Some("main".to_owned()),
25857        };
25858        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25859            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25860        }));
25861        assert!(!request_uses_unnegotiated_capability(
25862            &request,
25863            &baseline_capabilities().unwrap(),
25864        ));
25865        assert!(request_uses_unnegotiated_capability(&request, &[]));
25866
25867        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25868            .await
25869            .unwrap_err();
25870        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25871        assert!(!target.exists());
25872        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25873            .await
25874            .unwrap_err();
25875        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25876        assert!(!target.exists());
25877        shared
25878            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25879            .unwrap();
25880
25881        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25882            .await
25883            .unwrap();
25884        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25885            panic!("standalone workspace creation returned another response");
25886        };
25887        assert_eq!(workspace.workspace_id, workspace_id);
25888        assert!(target.join(".git").is_dir());
25889        let branch = run_git_bounded(
25890            target.to_str().unwrap(),
25891            &["branch", "--show-current"],
25892            GIT_OUTPUT_MAX_BYTES,
25893        )
25894        .await
25895        .unwrap();
25896        assert!(branch.success);
25897        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25898        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25899        assert_eq!(worktrees.len(), 1);
25900        let listed_root = WorkspaceConfig::new(
25901            WorkspaceId::new("listed-independent").unwrap(),
25902            &worktrees[0].path,
25903        )
25904        .unwrap();
25905        assert!(platform::roots_equal(
25906            listed_root.canonical_root(),
25907            windows_path_text(&workspace.canonical_root),
25908        ));
25909        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25910        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25911            snapshot.workspace_id == workspace_id
25912                && snapshot.canonical_root == workspace.canonical_root
25913        }));
25914        assert!(shared
25915            .history
25916            .lock()
25917            .unwrap_or_else(|poisoned| poisoned.into_inner())
25918            .events
25919            .iter()
25920            .any(|event| matches!(
25921                &event.event,
25922                NodeEvent::WorkspaceAdded { workspace }
25923                    if workspace.workspace_id == workspace_id
25924            )));
25925        std::fs::remove_dir_all(fixture_root).unwrap();
25926    }
25927
25928    #[tokio::test]
25929    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25930        let fixture_root = temporary_workspace_root("standalone-compensation");
25931        let created_target = fixture_root.join("created-target");
25932        let existing_target = fixture_root.join("existing-target");
25933        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25934        let invalid_state_path = invalid_state_parent.join("state.json");
25935        std::fs::create_dir_all(&existing_target).unwrap();
25936        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25937        let catalog = active_registry().unwrap();
25938        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25939        let primary = WorkspaceConfig::new(
25940            WorkspaceId::new("primary").unwrap(),
25941            std::env::current_dir().unwrap(),
25942        )
25943        .unwrap();
25944        let mut shared = NodeShared::new(
25945            handle,
25946            "fixture-token".to_owned(),
25947            NodeId::new("node-standalone-compensation").unwrap(),
25948            vec![primary],
25949            vec![agent("claude")],
25950        );
25951        shared.state_path = Some(invalid_state_path);
25952
25953        let created_recovery = shared
25954            .create_standalone_workspace(
25955                WorkspaceId::new("compensated").unwrap(),
25956                created_target.to_string_lossy().into_owned(),
25957                Some("main".to_owned()),
25958            )
25959            .await
25960            .unwrap_err();
25961        assert_eq!(
25962            created_recovery.code,
25963            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25964        );
25965        assert!(created_target.join(".git").is_dir());
25966        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25967            workspace.workspace_id.as_str() != "compensated"
25968        }));
25969
25970        let existing_recovery = shared
25971            .create_standalone_workspace(
25972                WorkspaceId::new("restored").unwrap(),
25973                existing_target.to_string_lossy().into_owned(),
25974                None,
25975            )
25976            .await
25977            .unwrap_err();
25978        assert_eq!(
25979            existing_recovery.code,
25980            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25981        );
25982        assert!(existing_target.is_dir());
25983        assert!(existing_target.join(".git").is_dir());
25984        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25985            workspace.workspace_id.as_str() != "restored"
25986        }));
25987
25988        let registered_empty = fixture_root.join("registered-empty");
25989        std::fs::create_dir(&registered_empty).unwrap();
25990        let catalog = active_registry().unwrap();
25991        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25992        let registered = WorkspaceConfig::new(
25993            WorkspaceId::new("registered-empty").unwrap(),
25994            &registered_empty,
25995        )
25996        .unwrap();
25997        let duplicate_shared = NodeShared::new(
25998            handle,
25999            "fixture-token".to_owned(),
26000            NodeId::new("node-standalone-duplicate-root").unwrap(),
26001            vec![registered],
26002            vec![agent("claude")],
26003        );
26004        let duplicate = duplicate_shared
26005            .create_standalone_workspace(
26006                WorkspaceId::new("duplicate-root").unwrap(),
26007                registered_empty.to_string_lossy().into_owned(),
26008                None,
26009            )
26010            .await
26011            .unwrap_err();
26012        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26013        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
26014        std::fs::remove_dir_all(fixture_root).unwrap();
26015    }
26016
26017    #[test]
26018    fn managed_record_id_collision_preserves_the_existing_record() {
26019        let catalog = active_registry().unwrap();
26020        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26021        let workspace = WorkspaceConfig::new(
26022            WorkspaceId::new("primary").unwrap(),
26023            std::env::current_dir().unwrap(),
26024        )
26025        .unwrap();
26026        let shared = NodeShared::new(
26027            handle,
26028            "fixture-token".to_owned(),
26029            NodeId::new("node-1").unwrap(),
26030            vec![workspace.clone()],
26031            vec![agent("claude")],
26032        );
26033        let record_id = SessionRecordId::new("sr-collision").unwrap();
26034        let original = ManagedSessionRecord {
26035            record_id: record_id.clone(),
26036            display_name: "original".to_owned(),
26037            provider: agent("claude"),
26038            mode: SessionMode::Pty,
26039            state: ManagedSessionState::Dormant,
26040            workspace_id: workspace.workspace_id,
26041            canonical_root: opaque_windows_path(workspace.canonical_root),
26042            provider_session: None,
26043            active_session: None,
26044            environment_profile: None,
26045            bundle: None,
26046            context_id: None,
26047            context: None,
26048            exported_context: None,
26049            task_binding: None,
26050            created_at_unix_ms: 1,
26051            updated_at_unix_ms: 1,
26052            last_error: None,
26053        };
26054        shared.insert_record(original.clone()).unwrap();
26055        let mut collision = original.clone();
26056        collision.display_name = "replacement".to_owned();
26057        let error = shared.insert_record(collision).unwrap_err();
26058        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26059        assert_eq!(shared.record(&record_id).unwrap(), original);
26060    }
26061
26062    #[test]
26063    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26064        let catalog = active_registry().unwrap();
26065        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26066        let workspace = WorkspaceConfig::new(
26067            WorkspaceId::new("primary").unwrap(),
26068            std::env::current_dir().unwrap(),
26069        )
26070        .unwrap();
26071        let shared = NodeShared::new(
26072            handle,
26073            "fixture-token".to_owned(),
26074            NodeId::new("node-1").unwrap(),
26075            vec![workspace.clone()],
26076            vec![agent("claude")],
26077        );
26078        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26079        shared
26080            .insert_record(ManagedSessionRecord {
26081                record_id: record_id.clone(),
26082                display_name: "runtime error".to_owned(),
26083                provider: agent("claude"),
26084                mode: SessionMode::Pty,
26085                state: ManagedSessionState::IdentityPending,
26086                workspace_id: workspace.workspace_id,
26087                canonical_root: opaque_windows_path(workspace.canonical_root),
26088                provider_session: None,
26089                active_session: None,
26090                environment_profile: None,
26091                bundle: None,
26092                context_id: None,
26093                context: None,
26094                exported_context: None,
26095                task_binding: None,
26096                created_at_unix_ms: 1,
26097                updated_at_unix_ms: 1,
26098                last_error: None,
26099            })
26100            .unwrap();
26101
26102        shared
26103            .mark_record_error(
26104                &record_id,
26105                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26106            )
26107            .unwrap();
26108        let record = shared.record(&record_id).unwrap();
26109        assert_eq!(record.state, ManagedSessionState::Unavailable);
26110        assert_eq!(
26111            record.last_error.as_deref(),
26112            Some("provider-runtime-failed"),
26113        );
26114        let snapshot = shared.snapshot();
26115        assert_eq!(snapshot.session_records, vec![record]);
26116    }
26117
26118    /// Regression coverage for the "operator sees `deadline` instead of the
26119    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
26120    /// able to learn a command's asynchronous kernel rejection (here,
26121    /// specifically classified as `UnsupportedTransport`) exactly once, and
26122    /// must not still find it after that -- proving the fast path this
26123    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
26124    /// loop) drains the reason instead of leaving it to be rediscovered (or
26125    /// missed) on a later poll.
26126    #[test]
26127    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26128        let catalog = active_registry().unwrap();
26129        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26130        let workspace = WorkspaceConfig::new(
26131            WorkspaceId::new("primary").unwrap(),
26132            std::env::current_dir().unwrap(),
26133        )
26134        .unwrap();
26135        let shared = NodeShared::new(
26136            handle,
26137            "fixture-token".to_owned(),
26138            NodeId::new("node-1").unwrap(),
26139            vec![workspace],
26140            vec![agent("claude")],
26141        );
26142
26143        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26144
26145        shared.record_rejected_command(
26146            CommandId(1),
26147            "agent 'claude' does not support transport Acp".to_owned(),
26148            true,
26149        );
26150        // An unrelated command_id must not see another command's reason.
26151        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26152
26153        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26154        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26155        assert!(reason.unsupported_transport);
26156
26157        // Consumed: a second read for the same command_id finds nothing.
26158        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26159
26160        // A rejection unrelated to transport support keeps its own
26161        // classification distinct, so the reader can choose the generic
26162        // `BackendOperationFailed` code instead of misreporting
26163        // `UnsupportedTransport`.
26164        shared.record_rejected_command(
26165            CommandId(3),
26166            "backend busy".to_owned(),
26167            false,
26168        );
26169        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26170        assert!(!unclassified.unsupported_transport);
26171    }
26172
26173    #[test]
26174    fn provider_identity_update_is_ignored_without_exact_session_policy() {
26175        let catalog = active_registry().unwrap();
26176        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26177        let workspace = WorkspaceConfig::new(
26178            WorkspaceId::new("primary").unwrap(),
26179            std::env::current_dir().unwrap(),
26180        )
26181        .unwrap();
26182        let shared = NodeShared::new(
26183            handle,
26184            "fixture-token".to_owned(),
26185            NodeId::new("node-identity-policy").unwrap(),
26186            vec![workspace.clone()],
26187            vec![agent("claude")],
26188        );
26189        let address = SessionAddress {
26190            workspace_id: workspace.workspace_id.clone(),
26191            session: SessionKey {
26192                instance_id: AgentInstanceId(90),
26193                generation: SessionGeneration(1),
26194            },
26195        };
26196        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26197        shared
26198            .insert_record(ManagedSessionRecord {
26199                record_id: record_id.clone(),
26200                display_name: "policy denied".to_owned(),
26201                provider: agent("claude"),
26202                mode: SessionMode::Pty,
26203                state: ManagedSessionState::IdentityPending,
26204                workspace_id: workspace.workspace_id,
26205                canonical_root: opaque_windows_path(workspace.canonical_root),
26206                provider_session: None,
26207                active_session: Some(address.clone()),
26208                environment_profile: None,
26209                bundle: None,
26210                context_id: None,
26211                context: None,
26212                exported_context: None,
26213                task_binding: None,
26214                created_at_unix_ms: 1,
26215                updated_at_unix_ms: 1,
26216                last_error: None,
26217            })
26218            .unwrap();
26219        let event = ControlEvent {
26220            sequence: 1,
26221            command_id: None,
26222            instance_id: address.session.instance_id,
26223            generation: address.session.generation,
26224            event: ControlEventKind::ProviderEvent {
26225                sequence: 1,
26226                source: gate4agent_types::ProviderSource {
26227                    family: AdapterFamily::PtySemantic,
26228                    binding: AdapterBinding::new(
26229                        gate4agent_types::AdapterId::new("claude").unwrap(),
26230                        "fixture/v1",
26231                        gate4agent_types::AdapterVerification::SyntheticFixture,
26232                    )
26233                    .unwrap(),
26234                },
26235                source_sequence: 1,
26236                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26237                    identity: gate4agent_types::ProviderSessionIdentity {
26238                        key: gate4agent_types::ProviderSessionKey::SessionId,
26239                        id: "provider-session".to_owned(),
26240                        transcript_path: None,
26241                    },
26242                },
26243            },
26244        };
26245
26246        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26247
26248        let record = shared.record(&record_id).unwrap();
26249        assert!(record.provider_session.is_none());
26250        assert_eq!(record.state, ManagedSessionState::IdentityPending);
26251        assert_eq!(record.active_session.as_ref(), Some(&address));
26252    }
26253
26254    #[test]
26255    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26256        let secondary_root = temporary_workspace_root("identity-scope");
26257        std::fs::create_dir_all(&secondary_root).unwrap();
26258        let catalog = active_registry().unwrap();
26259        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26260        let primary = WorkspaceConfig::new(
26261            WorkspaceId::new("primary").unwrap(),
26262            std::env::current_dir().unwrap(),
26263        )
26264        .unwrap();
26265        let secondary = WorkspaceConfig::new(
26266            WorkspaceId::new("secondary").unwrap(),
26267            &secondary_root,
26268        )
26269        .unwrap();
26270        let shared = NodeShared::new(
26271            handle,
26272            "fixture-token".to_owned(),
26273            NodeId::new("node-1").unwrap(),
26274            vec![primary.clone(), secondary.clone()],
26275            vec![agent("claude")],
26276        );
26277        let identity = gate4agent_types::ProviderSessionIdentity {
26278            key: gate4agent_types::ProviderSessionKey::SessionId,
26279            id: "provider-session".to_owned(),
26280            transcript_path: None,
26281        };
26282        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26283        shared
26284            .insert_record(ManagedSessionRecord {
26285                record_id: dormant_id.clone(),
26286                display_name: "dormant".to_owned(),
26287                provider: agent("claude"),
26288                mode: SessionMode::Pty,
26289                state: ManagedSessionState::Dormant,
26290                workspace_id: primary.workspace_id,
26291                canonical_root: opaque_windows_path(primary.canonical_root),
26292                provider_session: Some(identity.clone()),
26293                active_session: None,
26294                environment_profile: None,
26295                bundle: None,
26296                context_id: None,
26297                context: None,
26298                exported_context: None,
26299                task_binding: None,
26300                created_at_unix_ms: 1,
26301                updated_at_unix_ms: 1,
26302                last_error: None,
26303            })
26304            .unwrap();
26305        let address = SessionAddress {
26306            workspace_id: secondary.workspace_id.clone(),
26307            session: SessionKey {
26308                instance_id: AgentInstanceId(91),
26309                generation: SessionGeneration(1),
26310            },
26311        };
26312        let current_id = SessionRecordId::new("sr-current").unwrap();
26313        shared
26314            .insert_record(ManagedSessionRecord {
26315                record_id: current_id.clone(),
26316                display_name: "current".to_owned(),
26317                provider: agent("claude"),
26318                mode: SessionMode::Pty,
26319                state: ManagedSessionState::IdentityPending,
26320                workspace_id: secondary.workspace_id,
26321                canonical_root: opaque_windows_path(secondary.canonical_root),
26322                provider_session: None,
26323                active_session: Some(address.clone()),
26324                environment_profile: None,
26325                bundle: None,
26326                context_id: None,
26327                context: None,
26328                exported_context: None,
26329                task_binding: None,
26330                created_at_unix_ms: 2,
26331                updated_at_unix_ms: 2,
26332                last_error: None,
26333            })
26334            .unwrap();
26335        shared.bind_managed_session(
26336            &address,
26337            current_id.clone(),
26338            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26339            None,
26340        );
26341        let event = ControlEvent {
26342            sequence: 1,
26343            command_id: None,
26344            instance_id: address.session.instance_id,
26345            generation: address.session.generation,
26346            event: ControlEventKind::ProviderEvent {
26347                sequence: 1,
26348                source: gate4agent_types::ProviderSource {
26349                    family: gate4agent_types::AdapterFamily::PtySemantic,
26350                    binding: gate4agent_types::AdapterBinding::new(
26351                        gate4agent_types::AdapterId::new("claude").unwrap(),
26352                        "fixture/v1",
26353                        gate4agent_types::AdapterVerification::SyntheticFixture,
26354                    )
26355                    .unwrap(),
26356                },
26357                source_sequence: 1,
26358                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26359                    identity,
26360                },
26361            },
26362        };
26363        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
26364
26365        let binding = shared
26366            .session_bindings
26367            .lock()
26368            .unwrap_or_else(|poisoned| poisoned.into_inner())
26369            .get(&address.session.instance_id)
26370            .cloned()
26371            .unwrap();
26372        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
26373        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26374        let current = shared.record(&current_id).unwrap();
26375        assert_eq!(current.state, ManagedSessionState::Unavailable);
26376        assert_eq!(current.active_session.as_ref(), Some(&address));
26377        assert_eq!(
26378            current.last_error.as_deref(),
26379            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26380        );
26381        std::fs::remove_dir_all(secondary_root).unwrap();
26382    }
26383
26384    #[tokio::test]
26385    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26386        let root = temporary_workspace_root("semantic-provider-session");
26387        std::fs::create_dir_all(&root).unwrap();
26388        let state_path = root.join("state-v1.json");
26389        let catalog = active_registry().unwrap();
26390        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26391        let workspace = WorkspaceConfig::new(
26392            WorkspaceId::new("primary").unwrap(),
26393            &root,
26394        )
26395        .unwrap();
26396        let node_id = NodeId::new("node-semantic-identity").unwrap();
26397        let shared = NodeShared::new_with_incarnation(
26398            handle,
26399            "fixture-token".to_owned(),
26400            node_id.clone(),
26401            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26402            vec![workspace.clone()],
26403            vec![agent("claude")],
26404            ProviderRuntimeStatuses::default(),
26405            None,
26406            Vec::new(),
26407            Vec::new(),
26408            SpawnProfileRegistry::default(),
26409            None,
26410            None,
26411            None,
26412            Some(state_path.clone()),
26413            Vec::new(),
26414            Vec::new(),
26415            Vec::new(),
26416            Vec::new(),
26417            None,
26418            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26419        );
26420        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26421            key: gate4agent_types::ProviderSessionKey::SessionId,
26422            id: "provider-session".to_owned(),
26423            transcript_path: None,
26424        };
26425        let durable_id = SessionRecordId::new("sr-durable").unwrap();
26426        shared
26427            .insert_record(ManagedSessionRecord {
26428                record_id: durable_id.clone(),
26429                display_name: "durable".to_owned(),
26430                provider: agent("claude"),
26431                mode: SessionMode::Pty,
26432                state: ManagedSessionState::Dormant,
26433                workspace_id: workspace.workspace_id.clone(),
26434                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26435                provider_session: Some(persisted_identity.clone()),
26436                active_session: None,
26437                environment_profile: None,
26438                bundle: None,
26439                context_id: None,
26440                context: None,
26441                exported_context: None,
26442                task_binding: None,
26443                created_at_unix_ms: 1,
26444                updated_at_unix_ms: 1,
26445                last_error: None,
26446            })
26447            .unwrap();
26448        let address = SessionAddress {
26449            workspace_id: workspace.workspace_id.clone(),
26450            session: SessionKey {
26451                instance_id: AgentInstanceId(92),
26452                generation: SessionGeneration(2),
26453            },
26454        };
26455        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26456        shared
26457            .insert_record(ManagedSessionRecord {
26458                record_id: pending_id.clone(),
26459                display_name: "pending".to_owned(),
26460                provider: agent("claude"),
26461                mode: SessionMode::Pty,
26462                state: ManagedSessionState::IdentityPending,
26463                workspace_id: workspace.workspace_id.clone(),
26464                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26465                provider_session: None,
26466                active_session: Some(address.clone()),
26467                environment_profile: None,
26468                bundle: None,
26469                context_id: None,
26470                context: None,
26471                exported_context: None,
26472                task_binding: None,
26473                created_at_unix_ms: 2,
26474                updated_at_unix_ms: 2,
26475                last_error: None,
26476            })
26477            .unwrap();
26478        shared.bind_managed_session(
26479            &address,
26480            pending_id.clone(),
26481            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26482            None,
26483        );
26484        let observed_identity = gate4agent_types::ProviderSessionIdentity {
26485            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26486            ..persisted_identity
26487        };
26488        let event = ControlEvent {
26489            sequence: 1,
26490            command_id: None,
26491            instance_id: address.session.instance_id,
26492            generation: address.session.generation,
26493            event: ControlEventKind::ProviderEvent {
26494                sequence: 1,
26495                source: gate4agent_types::ProviderSource {
26496                    family: gate4agent_types::AdapterFamily::PtySemantic,
26497                    binding: gate4agent_types::AdapterBinding::new(
26498                        gate4agent_types::AdapterId::new("claude").unwrap(),
26499                        "fixture/v1",
26500                        gate4agent_types::AdapterVerification::SyntheticFixture,
26501                    )
26502                    .unwrap(),
26503                },
26504                source_sequence: 1,
26505                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26506                    identity: observed_identity.clone(),
26507                },
26508            },
26509        };
26510        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26511
26512        let snapshot = shared.snapshot();
26513        assert_eq!(snapshot.session_records.len(), 1);
26514        let live = &snapshot.session_records[0];
26515        assert_eq!(live.record_id, durable_id);
26516        assert_eq!(live.state, ManagedSessionState::Live);
26517        assert_eq!(live.active_session.as_ref(), Some(&address));
26518        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26519        let binding = shared
26520            .session_bindings
26521            .lock()
26522            .unwrap_or_else(|poisoned| poisoned.into_inner())
26523            .get(&address.session.instance_id)
26524            .cloned()
26525            .unwrap();
26526        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26527
26528        let mut duplicate = live.clone();
26529        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26530        duplicate.display_name = "duplicate".to_owned();
26531        duplicate.state = ManagedSessionState::Dormant;
26532        duplicate.active_session = None;
26533        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26534        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26535        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26536        assert_eq!(shared.snapshot().session_records.len(), 1);
26537
26538        let terminal_size = gate4agent_types::TerminalSize {
26539            rows: 24,
26540            columns: 80,
26541        };
26542        let (first_busy, second_busy) = tokio::join!(
26543            shared.resume_session_record(&durable_id, terminal_size, None),
26544            shared.resume_session_record(&durable_id, terminal_size, None),
26545        );
26546        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26547            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26548            assert_eq!(busy.message, "session-record-busy");
26549        }
26550
26551        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26552        assert_eq!(loaded.records.len(), 1);
26553        assert_eq!(loaded.records[0].record_id, durable_id);
26554        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26555        assert_eq!(
26556            loaded.records[0]
26557                .provider_session
26558                .as_ref()
26559                .unwrap()
26560                .transcript_path,
26561            None,
26562        );
26563        let catalog = active_registry().unwrap();
26564        let (reloaded_handle, _reloaded_runtime) =
26565            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26566        let reloaded = NodeShared::new_with_incarnation(
26567            reloaded_handle,
26568            "fixture-token".to_owned(),
26569            node_id,
26570            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26571            vec![workspace],
26572            vec![agent("claude")],
26573            ProviderRuntimeStatuses::default(),
26574            None,
26575            Vec::new(),
26576            Vec::new(),
26577            SpawnProfileRegistry::default(),
26578            None,
26579            None,
26580            None,
26581            None,
26582            loaded.records,
26583            loaded.managed_worktrees,
26584            loaded.managed_worktree_tombstones,
26585            loaded.materializations,
26586            loaded.warning,
26587            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26588        );
26589        assert_eq!(reloaded.snapshot().session_records.len(), 1);
26590        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26591        std::fs::remove_dir_all(root).unwrap();
26592    }
26593
26594    #[tokio::test]
26595    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26596        let secondary_root = temporary_workspace_root("workspace-restore");
26597        let other_root = temporary_workspace_root("workspace-conflict");
26598        std::fs::create_dir_all(&secondary_root).unwrap();
26599        std::fs::create_dir_all(&other_root).unwrap();
26600        let catalog = active_registry().unwrap();
26601        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26602        let primary = WorkspaceConfig::new(
26603            WorkspaceId::new("primary").unwrap(),
26604            std::env::current_dir().unwrap(),
26605        )
26606        .unwrap();
26607        let secondary = WorkspaceConfig::new(
26608            WorkspaceId::new("secondary").unwrap(),
26609            &secondary_root,
26610        )
26611        .unwrap();
26612        let shared = NodeShared::new(
26613            handle,
26614            "fixture-token".to_owned(),
26615            NodeId::new("node-1").unwrap(),
26616            vec![primary, secondary.clone()],
26617            vec![agent("claude")],
26618        );
26619        let record_id = SessionRecordId::new("sr-workspace").unwrap();
26620        shared
26621            .insert_record(ManagedSessionRecord {
26622                record_id: record_id.clone(),
26623                display_name: "workspace record".to_owned(),
26624                provider: agent("claude"),
26625                mode: SessionMode::Pty,
26626                state: ManagedSessionState::Dormant,
26627                workspace_id: secondary.workspace_id.clone(),
26628                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26629                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26630                    key: gate4agent_types::ProviderSessionKey::SessionId,
26631                    id: "provider-session".to_owned(),
26632                    transcript_path: None,
26633                }),
26634                active_session: None,
26635                environment_profile: None,
26636                bundle: None,
26637                context_id: None,
26638                context: None,
26639                exported_context: None,
26640                task_binding: None,
26641                created_at_unix_ms: 1,
26642                updated_at_unix_ms: 1,
26643                last_error: None,
26644            })
26645            .unwrap();
26646        shared.unregister_workspace(&secondary.workspace_id).unwrap();
26647        let unavailable = shared.record(&record_id).unwrap();
26648        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26649        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26650
26651        let conflict = shared
26652            .register_workspace(
26653                secondary.workspace_id.clone(),
26654                other_root.to_string_lossy().into_owned(),
26655            )
26656            .await
26657            .unwrap_err();
26658        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26659        shared
26660            .register_workspace(
26661                secondary.workspace_id,
26662                secondary_root.to_string_lossy().into_owned(),
26663            )
26664            .await
26665            .unwrap();
26666        let restored = shared.record(&record_id).unwrap();
26667        assert_eq!(restored.state, ManagedSessionState::Dormant);
26668        assert!(restored.last_error.is_none());
26669        std::fs::remove_dir_all(secondary_root).unwrap();
26670        std::fs::remove_dir_all(other_root).unwrap();
26671    }
26672
26673    #[test]
26674    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26675        let error = validate_workspace_request_root(
26676            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26677        )
26678        .unwrap_err();
26679        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26680        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26681        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26682    }
26683
26684    #[test]
26685    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26686        let catalog = active_registry().unwrap();
26687        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26688        let workspace = WorkspaceConfig::new(
26689            WorkspaceId::new("primary").unwrap(),
26690            std::env::current_dir().unwrap(),
26691        )
26692        .unwrap();
26693        let shared = NodeShared::new(
26694            handle,
26695            "fixture-token".to_owned(),
26696            NodeId::new("node-1").unwrap(),
26697            vec![workspace],
26698            vec![agent("claude")],
26699        );
26700        let publishers = 4_u64;
26701        let events_per_publisher = 256_u64;
26702        let start = std::sync::Barrier::new(publishers as usize + 1);
26703        let finished = AtomicU64::new(0);
26704
26705        std::thread::scope(|scope| {
26706            for _ in 0..publishers {
26707                scope.spawn(|| {
26708                    start.wait();
26709                    for _ in 0..events_per_publisher {
26710                        shared.publish(NodeEvent::ControllerChanged { controller: None });
26711                    }
26712                    finished.fetch_add(1, Ordering::Release);
26713                });
26714            }
26715            start.wait();
26716            while finished.load(Ordering::Acquire) != publishers {
26717                assert_resync_history_is_linearized(shared.resync(0));
26718                std::thread::yield_now();
26719            }
26720        });
26721        let response = shared.resync(0);
26722        assert_resync_history_is_linearized(response.clone());
26723        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26724            unreachable!("resync helper returned another response");
26725        };
26726        assert_eq!(event_sequence, publishers * events_per_publisher);
26727        assert_eq!(events.len() as u64, event_sequence);
26728    }
26729
26730    fn assert_resync_history_is_linearized(response: NodeResponse) {
26731        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26732            unreachable!("resync helper returned another response");
26733        };
26734        assert!(events.windows(2).all(|pair| {
26735            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26736        }));
26737        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26738    }
26739
26740    #[test]
26741    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26742        assert_eq!(
26743            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26744            r"C:\repo\worktree",
26745        );
26746        assert_eq!(
26747            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26748            r"\\server\share\repo",
26749        );
26750        assert!(validate_workspace_root(
26751            &WorkspaceId::new("network").unwrap(),
26752            r"\\server\share\repo",
26753        )
26754        .is_err());
26755        let workspace = WorkspaceConfig::new(
26756            WorkspaceId::new("primary").unwrap(),
26757            std::env::current_dir().unwrap(),
26758        )
26759        .unwrap();
26760        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26761    }
26762
26763    #[test]
26764    fn resume_rebinds_only_on_the_expected_authorized_generation() {
26765        let catalog = active_registry().unwrap();
26766        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26767        let workspace_id = WorkspaceId::new("primary").unwrap();
26768        let workspace = WorkspaceConfig::new(
26769            workspace_id.clone(),
26770            std::env::current_dir().unwrap(),
26771        )
26772        .unwrap();
26773        let shared = NodeShared::new(
26774            handle,
26775            "fixture-token".to_owned(),
26776            NodeId::new("node-1").unwrap(),
26777            vec![workspace],
26778            vec![agent("claude")],
26779        );
26780        let instance_id = AgentInstanceId(17);
26781        let original = SessionAddress {
26782            workspace_id,
26783            session: SessionKey {
26784                instance_id,
26785                generation: SessionGeneration(3),
26786            },
26787        };
26788        shared.bind_session(
26789            instance_id,
26790            original.workspace_id.clone(),
26791            original.session.generation,
26792        );
26793        shared
26794            .arm_resume(
26795                &original,
26796                CommandId(41),
26797                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26798            )
26799            .unwrap();
26800
26801        shared.publish_control(ControlEvent {
26802            sequence: 1,
26803            command_id: None,
26804            instance_id,
26805            generation: SessionGeneration(4),
26806            event: ControlEventKind::Running { process_id: None },
26807        });
26808        assert_eq!(
26809            shared.address_for(instance_id, SessionGeneration(3)),
26810            Some(original.clone()),
26811        );
26812        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26813
26814        shared.publish_control(ControlEvent {
26815            sequence: 2,
26816            command_id: Some(CommandId(42)),
26817            instance_id,
26818            generation: SessionGeneration(3),
26819            event: ControlEventKind::CommandRejected {
26820                message: "unrelated command".to_owned(),
26821            },
26822        });
26823
26824        shared.publish_control(ControlEvent {
26825            sequence: 3,
26826            command_id: None,
26827            instance_id,
26828            generation: SessionGeneration(4),
26829            event: ControlEventKind::ResumeAuthorized {
26830                session: gate4agent_types::ResumeSessionSummary {
26831                    key: gate4agent_types::ProviderSessionKey::SessionId,
26832                    id: "fixture-session".to_owned(),
26833                },
26834            },
26835        });
26836        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26837        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26838        assert_eq!(rebound.workspace_id, original.workspace_id);
26839        let history = shared
26840            .history
26841            .lock()
26842            .unwrap_or_else(|poisoned| poisoned.into_inner());
26843        assert!(history.events.iter().any(|envelope| matches!(
26844            &envelope.event,
26845            NodeEvent::Control { address, event }
26846                if address == &rebound
26847                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26848        )));
26849    }
26850
26851    #[test]
26852    fn all_node_pty_prompts_use_bracketed_paste_framing() {
26853        for agent in ["claude", "codex", "kimi"] {
26854            assert_eq!(
26855                prompt_framing(&AgentId::new(agent).unwrap()),
26856                PromptFraming::BracketedPaste
26857            );
26858        }
26859    }
26860
26861    fn generous_walk_deadline() -> Instant {
26862        Instant::now() + Duration::from_secs(30)
26863    }
26864
26865    #[test]
26866    fn workspace_tree_is_relative_and_skips_heavy_directories() {
26867        let root = temporary_workspace_root("tree");
26868        std::fs::create_dir_all(root.join("src")).unwrap();
26869        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26870        std::fs::create_dir_all(root.join("target/debug")).unwrap();
26871        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26872        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26873        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26874        // A renamed/agent-scoped build directory (`target-a2`-style, per the
26875        // real repository's own build layout) that carries the Cache
26876        // Directory Tagging Specification marker cargo writes into every
26877        // `--target-dir` it creates: must be skipped even though its name
26878        // does not exactly match `target`.
26879        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26880        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26881        // A directory that merely *starts with* `target` but carries no
26882        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
26883        // on the name alone, so this stays in the response.
26884        std::fs::create_dir_all(root.join("target-notes")).unwrap();
26885        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26886
26887        let (entries, truncated, _budget) =
26888            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26889        assert!(!truncated);
26890        assert!(entries.iter().any(|entry| {
26891            entry.relative_path.as_utf8() == Some("src")
26892                && entry.kind == WorkspaceEntryKind::Directory
26893        }));
26894        assert!(entries.iter().any(|entry| {
26895            entry.relative_path.as_utf8() == Some("src/lib.rs")
26896                && entry.kind == WorkspaceEntryKind::File
26897        }));
26898        assert!(entries.iter().any(|entry| {
26899            entry.relative_path.as_utf8() == Some("target-notes")
26900                && entry.kind == WorkspaceEntryKind::Directory
26901        }));
26902        assert!(entries.iter().any(|entry| {
26903            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26904                && entry.kind == WorkspaceEntryKind::File
26905        }));
26906        assert!(entries.iter().all(|entry| {
26907            let path = entry.relative_path.as_utf8().unwrap();
26908            !path.starts_with(".git")
26909                && !(path == "target" || path.starts_with("target/"))
26910                && !(path == "target-a2" || path.starts_with("target-a2/"))
26911                && !path.starts_with("node_modules")
26912        }));
26913
26914        std::fs::remove_dir_all(&root).unwrap();
26915    }
26916
26917    #[test]
26918    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26919        let root = temporary_workspace_root("tree-entry-cap");
26920        std::fs::create_dir_all(&root).unwrap();
26921        for index in 0..20 {
26922            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26923        }
26924
26925        let (entries, truncated, budget) =
26926            collect_workspace_entries(&root, generous_walk_deadline(), 5);
26927        assert!(truncated);
26928        assert!(budget.entry_cap_exceeded);
26929        assert!(!budget.time_budget_exceeded);
26930        assert!(entries.len() <= 5);
26931
26932        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26933            .expect("an entry-cap-truncated walk must report a truncation marker");
26934        assert!(inspection_truncation.walk_entry_cap_exceeded);
26935        assert!(!inspection_truncation.walk_time_budget_exceeded);
26936        assert!(!inspection_truncation.git_time_budget_exceeded);
26937        assert!(inspection_truncation.entries_visited >= 5);
26938
26939        std::fs::remove_dir_all(&root).unwrap();
26940    }
26941
26942    #[test]
26943    fn workspace_walk_within_budget_reports_no_truncation() {
26944        let root = temporary_workspace_root("tree-no-truncation");
26945        std::fs::create_dir_all(&root).unwrap();
26946        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26947
26948        let (_entries, truncated, budget) =
26949            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26950        assert!(!truncated);
26951        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26952
26953        std::fs::remove_dir_all(&root).unwrap();
26954    }
26955
26956    #[test]
26957    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26958        let root = temporary_workspace_root("tree-prefix-siblings");
26959        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26960        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26961        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26962        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26963
26964        let (entries, truncated, _budget) =
26965            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26966        assert!(!truncated);
26967        let paths = entries
26968            .iter()
26969            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26970            .collect::<Vec<_>>();
26971        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26972        assert_eq!(
26973            paths,
26974            vec![
26975                "gate4agent-c2".to_owned(),
26976                "gate4agent-c2-client".to_owned(),
26977                "gate4agent-c2-client/lib.rs".to_owned(),
26978                "gate4agent-c2/src".to_owned(),
26979                "gate4agent-c2/src/lib.rs".to_owned(),
26980            ],
26981        );
26982
26983        std::fs::remove_dir_all(&root).unwrap();
26984    }
26985
26986    #[test]
26987    fn git_parsers_cap_status_and_commit_results() {
26988        let mut status_output = Vec::new();
26989        for index in 0..=GIT_STATUS_MAX_ENTRIES {
26990            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26991        }
26992        let mut snapshot = git_snapshot_for_parser();
26993        parse_git_status(&status_output, &mut snapshot);
26994        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26995        assert!(snapshot.truncated);
26996        assert_eq!(snapshot.status[0].index_status, " ");
26997        assert_eq!(snapshot.status[0].worktree_status, "M");
26998
26999        snapshot.truncated = false;
27000        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27001            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27002            .collect::<String>();
27003        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27004        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27005        assert!(snapshot.truncated);
27006    }
27007
27008    #[test]
27009    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27010        let mut snapshot = git_snapshot_for_parser();
27011        parse_git_status(
27012            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27013            &mut snapshot,
27014        );
27015
27016        assert!(!snapshot.truncated);
27017        assert_eq!(snapshot.status.len(), 3);
27018        assert_eq!(snapshot.status[0].index_status, "R");
27019        assert_eq!(snapshot.status[0].worktree_status, " ");
27020        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27021        assert_eq!(
27022            snapshot.status[0]
27023                .previous_path
27024                .as_ref()
27025                .and_then(RepositoryPath::as_utf8),
27026            Some("src/previous name.rs"),
27027        );
27028        assert_eq!(snapshot.status[1].index_status, " ");
27029        assert_eq!(snapshot.status[1].worktree_status, "C");
27030        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27031        assert_eq!(
27032            snapshot.status[1]
27033                .previous_path
27034                .as_ref()
27035                .and_then(RepositoryPath::as_utf8),
27036            Some("src/original.rs"),
27037        );
27038        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27039        assert!(snapshot.status[2].previous_path.is_none());
27040    }
27041
27042    #[test]
27043    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27044        let mut snapshot = git_snapshot_for_parser();
27045        parse_git_status(
27046            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
27047            &mut snapshot,
27048        );
27049
27050        assert!(snapshot.truncated);
27051        assert_eq!(snapshot.status.len(), 1);
27052        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27053        assert!(snapshot.status[0].previous_path.is_none());
27054    }
27055
27056    #[tokio::test]
27057    async fn observer_can_browse_host_directories_without_controller() {
27058        let root = temporary_workspace_root("host-directory-observer");
27059        std::fs::create_dir_all(root.join("directory-b")).unwrap();
27060        std::fs::create_dir_all(root.join("directory-a")).unwrap();
27061        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27062        let workspace_id = WorkspaceId::new("primary").unwrap();
27063        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27064        let catalog = active_registry().unwrap();
27065        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27066        let shared = NodeShared::new(
27067            handle,
27068            "fixture-token".to_owned(),
27069            NodeId::new("node-1").unwrap(),
27070            vec![workspace],
27071            vec![agent("claude")],
27072        );
27073        let request = NodeRequest::BrowseHostDirectories {
27074            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27075            after: None,
27076        };
27077        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27078            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27079        }));
27080        assert!(!request_uses_unnegotiated_capability(
27081            &request,
27082            &baseline_capabilities().unwrap(),
27083        ));
27084        assert!(request_uses_unnegotiated_capability(&request, &[]));
27085
27086        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27087            .await
27088            .unwrap();
27089        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27090            panic!("host directory browse returned another response");
27091        };
27092        assert_eq!(
27093            listing
27094                .entries
27095                .iter()
27096                .map(|entry| entry.display_name.as_str())
27097                .collect::<Vec<_>>(),
27098            vec!["directory-a", "directory-b"],
27099        );
27100        assert!(listing.entries.iter().all(|entry| !entry.is_link));
27101        assert!(!listing.incomplete);
27102        std::fs::remove_dir_all(root).unwrap();
27103    }
27104
27105    #[tokio::test]
27106    async fn observer_can_inspect_only_a_registered_workspace() {
27107        let root = temporary_workspace_root("observer");
27108        std::fs::create_dir_all(root.join("src")).unwrap();
27109        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27110        let workspace_id = WorkspaceId::new("primary").unwrap();
27111        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27112        let catalog = active_registry().unwrap();
27113        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27114        let shared = NodeShared::new(
27115            handle,
27116            "fixture-token".to_owned(),
27117            NodeId::new("node-1").unwrap(),
27118            vec![workspace],
27119            vec![agent("claude")],
27120        );
27121
27122        let response = process_request_inner(
27123            &shared,
27124            77,
27125            ClientRole::Observer,
27126            NodeRequest::InspectWorkspace {
27127                workspace_id: workspace_id.clone(),
27128            },
27129        )
27130        .await
27131        .unwrap();
27132        let NodeResponse::WorkspaceInspected { inspection } = response else {
27133            panic!("workspace inspection returned another response");
27134        };
27135        assert_eq!(inspection.workspace_id, workspace_id);
27136        assert!(inspection.entries.iter().any(|entry| {
27137            entry.relative_path.as_utf8() == Some("src/main.rs")
27138        }));
27139
27140        let unknown = process_request_inner(
27141            &shared,
27142            77,
27143            ClientRole::Observer,
27144            NodeRequest::InspectWorkspace {
27145                workspace_id: WorkspaceId::new("unknown").unwrap(),
27146            },
27147        )
27148        .await
27149        .unwrap_err();
27150        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27151        std::fs::remove_dir_all(&root).unwrap();
27152    }
27153
27154    #[test]
27155    fn context_pack_source_accepts_only_running_or_successful_exit() {
27156        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27157        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27158            exit_code: Some(0),
27159        }));
27160        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27161            exit_code: Some(1),
27162        }));
27163        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27164            exit_code: None,
27165        }));
27166        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27167            message: "provider failed".to_owned(),
27168        }));
27169        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27170        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27171        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27172    }
27173
27174    #[test]
27175    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27176        let git = git_snapshot_for_parser();
27177        let first = ContextPackRepositoryObservation {
27178            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27179            git: git.clone(),
27180            files: Vec::new(),
27181        };
27182        let second = ContextPackRepositoryObservation {
27183            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27184            git,
27185            files: Vec::new(),
27186        };
27187
27188        let error = stable_context_pack_repository(first, second).unwrap_err();
27189
27190        assert_eq!(
27191            error.code,
27192            NodeFailureCode::ContextPackMaterializationFailed,
27193        );
27194    }
27195
27196    #[test]
27197    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27198        let git = git_snapshot_for_parser();
27199        let first = ContextPackRepositoryObservation {
27200            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27201            git: git.clone(),
27202            files: vec![ContextPackRepositoryFileSource::utf8(
27203                "README.md",
27204                "first\n".to_owned(),
27205                6,
27206            )],
27207        };
27208        let second = ContextPackRepositoryObservation {
27209            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27210            git,
27211            files: vec![ContextPackRepositoryFileSource::utf8(
27212                "README.md",
27213                "other\n".to_owned(),
27214                6,
27215            )],
27216        };
27217
27218        let error = stable_context_pack_repository(first, second).unwrap_err();
27219
27220        assert_eq!(
27221            error.code,
27222            NodeFailureCode::ContextPackMaterializationFailed,
27223        );
27224    }
27225
27226    #[tokio::test]
27227    async fn native_session_catalog_zero_active_session_fixture() {
27228        let workspace_root = temporary_workspace_root("native-catalog-workspace");
27229        let history_root = temporary_workspace_root("native-catalog-history");
27230        let outside_root = temporary_workspace_root("native-catalog-outside");
27231        std::fs::create_dir_all(&workspace_root).unwrap();
27232        std::fs::create_dir_all(&history_root).unwrap();
27233        std::fs::create_dir_all(&outside_root).unwrap();
27234        let transcript = |session_id: &str, cwd: &Path, title: &str| {
27235            [
27236                serde_json::json!({
27237                    "type": "user",
27238                    "sessionId": session_id,
27239                    "cwd": cwd.to_str().unwrap(),
27240                    "message": { "content": "raw user text must not cross the catalog wire" }
27241                }),
27242                serde_json::json!({
27243                    "type": "assistant",
27244                    "sessionId": session_id,
27245                    "cwd": cwd.to_str().unwrap(),
27246                    "message": { "model": "claude-sonnet", "content": "raw answer" }
27247                }),
27248                serde_json::json!({
27249                    "type": "ai-title",
27250                    "sessionId": session_id,
27251                    "aiTitle": title
27252                }),
27253            ]
27254            .into_iter()
27255            .map(|record| record.to_string())
27256            .collect::<Vec<_>>()
27257            .join("\n")
27258        };
27259        std::fs::write(
27260            history_root.join("stable-session.jsonl"),
27261            transcript("stable-session", &workspace_root, "Workspace review"),
27262        )
27263        .unwrap();
27264        std::fs::write(
27265            history_root.join("outside-session.jsonl"),
27266            transcript("outside-session", &outside_root, "Outside review"),
27267        )
27268        .unwrap();
27269        let history = NativeHistoryConfig::new(vec![
27270            NativeHistoryRoot::new(
27271                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27272                HistorySourceLayout::SingleNdjson,
27273                history_root.clone(),
27274            )
27275            .unwrap(),
27276        ])
27277        .unwrap();
27278        let catalog = active_registry().unwrap();
27279        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27280        assert!(handle.snapshot().sessions.is_empty());
27281        let workspace_id = WorkspaceId::new("primary").unwrap();
27282        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27283        let mut shared = NodeShared::new(
27284            handle,
27285            "fixture-token".to_owned(),
27286            NodeId::new("node-native-catalog").unwrap(),
27287            vec![workspace],
27288            vec![agent("claude")],
27289        );
27290        shared.native_session_catalog = Some(Arc::new(Mutex::new(
27291            NativeSessionCatalogAuthority::new(history),
27292        )));
27293        let route = NativeSessionCatalogRoute::workspace(
27294            workspace_id.clone(),
27295            agent("claude"),
27296        );
27297        let request = NodeRequest::CatalogNativeSessions {
27298            route: route.clone(),
27299            limit: 2,
27300        };
27301        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27302            .await
27303            .unwrap_err();
27304        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27305        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27306            .await
27307            .unwrap();
27308        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27309            panic!("native catalog returned a different response");
27310        };
27311        let summary = summary.expect("node must emit native session catalog summary metadata");
27312        assert_eq!(entries.len(), 1);
27313        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27314        assert_eq!(entries[0].external_group, None);
27315        assert_eq!(entries[0].record_id, None);
27316        assert_eq!(entries[0].title, None);
27317        assert_eq!(entries[0].model, None);
27318        assert_eq!(entries[0].message_count, 0);
27319        let encoded_entries = serde_json::to_string(&entries).unwrap();
27320        assert!(!encoded_entries.contains("stable-session"));
27321        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27322        let page_request = NodeRequest::PageNativeSessions {
27323            route: route.clone(),
27324            window: crate::protocol::NativeSessionCatalogWindow::Recent,
27325            catalog_revision: summary.catalog_revision,
27326            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27327            after_selection_id: None,
27328            limit: 2,
27329        };
27330        let observer = process_request_inner(
27331            &shared,
27332            7,
27333            ClientRole::Observer,
27334            page_request.clone(),
27335        )
27336        .await
27337        .unwrap_err();
27338        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27339        let page_response = process_request_inner(
27340            &shared,
27341            7,
27342            ClientRole::Operator,
27343            page_request.clone(),
27344        )
27345        .await
27346        .unwrap();
27347        assert!(matches!(
27348            page_response,
27349            NodeResponse::NativeSessionsPaged { page, .. }
27350                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27351        ));
27352        let stale = process_request_inner(
27353            &shared,
27354            7,
27355            ClientRole::Operator,
27356            NodeRequest::PageNativeSessions {
27357                route: route.clone(),
27358                window: crate::protocol::NativeSessionCatalogWindow::Recent,
27359                catalog_revision: summary.catalog_revision.wrapping_add(1),
27360                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27361                after_selection_id: None,
27362                limit: 2,
27363            },
27364        )
27365        .await
27366        .unwrap_err();
27367        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27368        let selection = NativeSessionSelection {
27369            route: route.clone(),
27370            catalog_revision: summary.catalog_revision,
27371            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27372            selection_id: entries[0].selection_id.clone(),
27373        };
27374        let preview_response = process_request_inner(
27375            &shared,
27376            7,
27377            ClientRole::Operator,
27378            NodeRequest::PreviewNativeSession {
27379                selection: selection.clone(),
27380                message_limit: 1,
27381            },
27382        )
27383        .await
27384        .unwrap();
27385        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27386            panic!("native preview returned a different response");
27387        };
27388        assert_eq!(preview.messages.len(), 1);
27389        assert!(preview.message_count_exact);
27390        assert_eq!(preview.messages[0].text, "raw answer");
27391        assert!(preview.truncated);
27392
27393        let index_request = NodeRequest::IndexNativeSession {
27394            selection: selection.clone(),
27395            display_name: "Saved Claude".to_owned(),
27396        };
27397        let observer = process_request_inner(
27398            &shared,
27399            7,
27400            ClientRole::Observer,
27401            index_request.clone(),
27402        )
27403        .await
27404        .unwrap_err();
27405        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27406        let without_controller = process_request_inner(
27407            &shared,
27408            7,
27409            ClientRole::Operator,
27410            index_request.clone(),
27411        )
27412        .await
27413        .unwrap_err();
27414        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27415        let acquired = process_request_inner(
27416            &shared,
27417            7,
27418            ClientRole::Operator,
27419            NodeRequest::AcquireController { lease_ms: 5_000 },
27420        )
27421        .await
27422        .unwrap();
27423        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27424        let indexed = process_request_inner(
27425            &shared,
27426            7,
27427            ClientRole::Operator,
27428            index_request,
27429        )
27430        .await
27431        .unwrap();
27432        assert!(indexed.native_session_index_contract_is_valid());
27433        let NodeResponse::NativeSessionIndexed {
27434            selection: echoed_selection,
27435            record,
27436        } = indexed else {
27437            panic!("native session index returned a different response");
27438        };
27439        assert_eq!(echoed_selection, selection);
27440        let recataloged = process_request_inner(
27441            &shared,
27442            7,
27443            ClientRole::Operator,
27444            NodeRequest::CatalogNativeSessions {
27445                route,
27446                limit: 2,
27447            },
27448        )
27449        .await
27450        .unwrap();
27451        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27452            panic!("native catalog returned a different response after indexing");
27453        };
27454        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27455
27456        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27457        let external_catalog = process_request_inner(
27458            &shared,
27459            7,
27460            ClientRole::Operator,
27461            NodeRequest::CatalogNativeSessions {
27462                route: unregistered_route.clone(),
27463                limit: 2,
27464            },
27465        )
27466        .await
27467        .unwrap();
27468        let external_json = serde_json::to_string(&external_catalog).unwrap();
27469        assert!(!external_json.contains("outside-session"));
27470        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27471        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27472        assert!(!external_json.contains("\"session_id\""));
27473        assert!(!external_json.contains("\"cwd\""));
27474        assert!(!external_json.contains("\"path\""));
27475        let NodeResponse::NativeSessionsCataloged {
27476            route: echoed_route,
27477            entries: external_entries,
27478            summary: Some(external_summary),
27479        } = external_catalog
27480        else {
27481            panic!("unregistered native catalog returned a different response");
27482        };
27483        assert_eq!(echoed_route, unregistered_route);
27484        assert_eq!(external_entries.len(), 1);
27485        assert_eq!(external_entries[0].record_id, None);
27486        let external_group = external_entries[0]
27487            .external_group
27488            .as_ref()
27489            .expect("unregistered session must have an external group");
27490        assert_eq!(
27491            external_group.kind,
27492            gate4agent_types::NativeSessionExternalGroupKind::Project,
27493        );
27494        assert!(external_group.group_id.starts_with("external-"));
27495        assert_eq!(
27496            external_group.display_name,
27497            outside_root.file_name().unwrap().to_string_lossy(),
27498        );
27499        let external_selection = NativeSessionSelection {
27500            route: unregistered_route,
27501            catalog_revision: external_summary.catalog_revision,
27502            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27503            selection_id: external_entries[0].selection_id.clone(),
27504        };
27505        let external_preview = process_request_inner(
27506            &shared,
27507            7,
27508            ClientRole::Operator,
27509            NodeRequest::PreviewNativeSession {
27510                selection: external_selection.clone(),
27511                message_limit: 1,
27512            },
27513        )
27514        .await
27515        .unwrap();
27516        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27517        assert!(!external_preview_json.contains("outside-session"));
27518        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27519        assert!(!external_preview_json.contains("\"session_id\""));
27520        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27521            panic!("unregistered native preview returned a different response");
27522        };
27523        assert_eq!(preview.messages.len(), 1);
27524        let external_index = process_request_inner(
27525            &shared,
27526            7,
27527            ClientRole::Operator,
27528            NodeRequest::IndexNativeSession {
27529                selection: external_selection,
27530                display_name: "External Claude".to_owned(),
27531            },
27532        )
27533        .await
27534        .unwrap_err();
27535        assert_eq!(
27536            external_index.code,
27537            NodeFailureCode::WorkspaceRegistrationRequired,
27538        );
27539        let record_response = process_request_inner(
27540            &shared,
27541            7,
27542            ClientRole::Operator,
27543            NodeRequest::PreviewSessionRecord {
27544                record_id: record.record_id.clone(),
27545                message_limit: 2,
27546            },
27547        )
27548        .await
27549        .unwrap();
27550        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27551            panic!("record preview returned a different response");
27552        };
27553        assert_eq!(record_id, record.record_id);
27554        assert_eq!(preview.messages.len(), 2);
27555        let encoded = serde_json::to_string(&preview).unwrap();
27556        assert!(!encoded.contains("stable-session"));
27557        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27558        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27559            panic!("record preview history summary returned a different response");
27560        };
27561        let summaries = events
27562            .iter()
27563            .filter_map(|envelope| match &envelope.event {
27564                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27565                    if record_id == &record.record_id =>
27566                {
27567                    Some(*summary)
27568                }
27569                _ => None,
27570            })
27571            .collect::<Vec<_>>();
27572        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27573        let summary_json = serde_json::to_string(&summaries).unwrap();
27574        for private in [
27575            "stable-session",
27576            "raw user text must not cross the catalog wire",
27577            "raw answer",
27578            "Workspace review",
27579            "claude-sonnet",
27580        ] {
27581            assert!(!summary_json.contains(private), "{private}");
27582        }
27583        let identity = record.provider_session.clone().unwrap();
27584        assert_eq!(
27585            shared.revalidate_session_record_preview(&record, &identity),
27586            Ok(())
27587        );
27588        shared.remove_record_memory(&record.record_id);
27589        assert_eq!(
27590            shared
27591                .revalidate_session_record_preview(&record, &identity)
27592                .unwrap_err()
27593                .code,
27594            NodeFailureCode::UnknownSessionRecord
27595        );
27596        let released = process_request_inner(
27597            &shared,
27598            7,
27599            ClientRole::Operator,
27600            NodeRequest::ReleaseController,
27601        )
27602        .await
27603        .unwrap();
27604        assert!(matches!(released, NodeResponse::Controller { controller: None }));
27605        assert!(shared.controller_state().is_none());
27606        std::fs::remove_dir_all(workspace_root).unwrap();
27607        std::fs::remove_dir_all(history_root).unwrap();
27608        std::fs::remove_dir_all(outside_root).unwrap();
27609    }
27610
27611    #[tokio::test]
27612    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27613        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27614        std::fs::create_dir_all(&history_root).unwrap();
27615        let history = NativeHistoryConfig::new(vec![
27616            NativeHistoryRoot::new(
27617                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27618                HistorySourceLayout::SingleNdjson,
27619                history_root.clone(),
27620            )
27621            .unwrap(),
27622        ])
27623        .unwrap();
27624        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27625        let held = catalog.lock().unwrap();
27626        let result = timeout(
27627            Duration::from_secs(1),
27628            run_native_session_catalog_operation(
27629                Arc::clone(&catalog),
27630                "native session catalog",
27631                |_| Ok::<(), std::convert::Infallible>(()),
27632            ),
27633        )
27634        .await
27635        .expect("concurrent native catalog request must not wait for the operation deadline")
27636        .unwrap_err();
27637        assert_eq!(result.code, NodeFailureCode::BackendBusy);
27638        drop(held);
27639        std::fs::remove_dir_all(history_root).unwrap();
27640    }
27641
27642    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27643        let unique = std::time::SystemTime::now()
27644            .duration_since(std::time::UNIX_EPOCH)
27645            .unwrap()
27646            .as_nanos();
27647        std::env::temp_dir().join(format!(
27648            "gate4agent-node-{label}-{}-{unique}",
27649            std::process::id(),
27650        ))
27651    }
27652
27653    fn git_snapshot_for_parser() -> GitSnapshot {
27654        GitSnapshot {
27655            is_repository: true,
27656            branch: Some("main".to_owned()),
27657            status: Vec::new(),
27658            recent_commits: Vec::new(),
27659            worktrees: Vec::new(),
27660            managed_worktree: None,
27661            truncated: false,
27662            diagnostic: None,
27663        }
27664    }
27665
27666    #[tokio::test]
27667    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27668        let root = temporary_workspace_root("managed-git-scope");
27669        std::fs::create_dir_all(&root).unwrap();
27670        run_test_git(&root, &["init"]);
27671        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27672        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27673        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27674        run_test_git(&root, &["add", "seed.txt"]);
27675        run_test_git(&root, &["commit", "-m", "seed"]);
27676        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27677
27678        let workspace_id = WorkspaceId::new("managed-a").unwrap();
27679        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27680        let catalog = active_registry().unwrap();
27681        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27682        let shared = NodeShared::new(
27683            handle,
27684            "fixture-token".to_owned(),
27685            NodeId::new("node-1").unwrap(),
27686            vec![workspace],
27687            vec![agent("claude")],
27688        );
27689        let mut lease = ManagedWorktreeLeaseRecord {
27690            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27691            source_workspace_id: WorkspaceId::new("primary").unwrap(),
27692            workspace_id: workspace_id.clone(),
27693            profile_id: WorktreeProfileId::new("default").unwrap(),
27694            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27695            target_root: root.to_string_lossy().into_owned(),
27696            branch: "gate4agent/a".to_owned(),
27697            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27698            expected_head: None,
27699            retention: ManagedWorktreeRetention::Retain,
27700            state: ManagedWorktreeLeaseState::InUse,
27701            session_holders: vec![ManagedWorktreeSessionHolder {
27702                incarnation_id: shared.incarnation_id,
27703                instance_id: AgentInstanceId(7),
27704                generation: SessionGeneration(1),
27705            }],
27706            record_holders: Vec::new(),
27707            cleanup_failure: None,
27708            created_at_unix_ms: 1,
27709            updated_at_unix_ms: 1,
27710        };
27711        *shared
27712            .managed_worktrees
27713            .lock()
27714            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27715            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27716
27717        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27718        let scope = inspection.git.managed_worktree.unwrap();
27719        assert_eq!(scope.lease_id, lease.lease_id);
27720        assert_eq!(scope.branch, "gate4agent/a");
27721        assert_eq!(scope.active_session_count, 1);
27722
27723        lease.branch = "gate4agent/b".to_owned();
27724        *shared
27725            .managed_worktrees
27726            .lock()
27727            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27728            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27729        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27730        assert!(mismatch.git.managed_worktree.is_none());
27731        std::fs::remove_dir_all(root).unwrap();
27732    }
27733
27734    #[tokio::test]
27735    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27736        let root = temporary_workspace_root("workspace-file-write-controller");
27737        std::fs::create_dir_all(root.join("src")).unwrap();
27738        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27739        let workspace_id = WorkspaceId::new("primary").unwrap();
27740        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27741        let catalog = active_registry().unwrap();
27742        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27743        let shared = NodeShared::new(
27744            handle,
27745            "fixture-token".to_owned(),
27746            NodeId::new("node-1").unwrap(),
27747            vec![workspace],
27748            vec![agent("claude")],
27749        );
27750        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27751        let expected_revision = workspace_file_revision(b"old\n");
27752        let request = NodeRequest::WriteWorkspaceFile {
27753            workspace_id: workspace_id.clone(),
27754            path: path.clone(),
27755            expected_revision: expected_revision.clone(),
27756            text: "new\n".to_owned(),
27757        };
27758        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27759            .await
27760            .unwrap_err();
27761        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27762        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27763            .await
27764            .unwrap_err();
27765        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27766        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27767        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27768        let NodeResponse::WorkspaceFileWritten { file } = response else {
27769            panic!("workspace file write returned another response");
27770        };
27771        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27772        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27773        let stale = process_request_inner(
27774            &shared,
27775            77,
27776            ClientRole::Operator,
27777            NodeRequest::WriteWorkspaceFile {
27778                workspace_id,
27779                path,
27780                expected_revision,
27781                text: "lost update\n".to_owned(),
27782            },
27783        )
27784        .await
27785        .unwrap_err();
27786        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27787        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27788        std::fs::remove_dir_all(root).unwrap();
27789    }
27790
27791    #[tokio::test]
27792    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27793        let root = temporary_workspace_root("workspace-entry-create-controller");
27794        std::fs::create_dir_all(root.join("src")).unwrap();
27795        let workspace_id = WorkspaceId::new("primary").unwrap();
27796        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27797        let catalog = active_registry().unwrap();
27798        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27799        let shared = NodeShared::new(
27800            handle,
27801            "fixture-token".to_owned(),
27802            NodeId::new("node-1").unwrap(),
27803            vec![workspace],
27804            vec![agent("claude")],
27805        );
27806        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27807        let request = NodeRequest::CreateWorkspaceFile {
27808            workspace_id: workspace_id.clone(),
27809            path: path.clone(),
27810        };
27811        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27812            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27813        }));
27814        assert!(!request_uses_unnegotiated_capability(
27815            &request,
27816            &baseline_capabilities().unwrap(),
27817        ));
27818        assert!(request_uses_unnegotiated_capability(&request, &[]));
27819        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27820            .await
27821            .unwrap_err();
27822        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27823        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27824            .await
27825            .unwrap_err();
27826        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27827        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27828
27829        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27830            .await
27831            .unwrap();
27832        let NodeResponse::WorkspaceFileCreated { file } = response else {
27833            panic!("workspace file create returned another response");
27834        };
27835        assert_eq!(file.workspace_id, workspace_id);
27836        assert_eq!(file.path, path);
27837        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27838        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27839
27840        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27841            .await
27842            .unwrap_err();
27843        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27844
27845        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27846        let response = process_request_inner(
27847            &shared,
27848            77,
27849            ClientRole::Operator,
27850            NodeRequest::CreateWorkspaceDirectory {
27851                workspace_id: workspace_id.clone(),
27852                path: directory_path.clone(),
27853            },
27854        )
27855        .await
27856        .unwrap();
27857        assert_eq!(
27858            response,
27859            NodeResponse::WorkspaceDirectoryCreated {
27860                workspace_id: workspace_id.clone(),
27861                entry: WorkspaceEntry {
27862                    relative_path: directory_path,
27863                    kind: WorkspaceEntryKind::Directory,
27864                },
27865            },
27866        );
27867        assert!(root.join("src/nested").is_dir());
27868
27869        let missing_parent = process_request_inner(
27870            &shared,
27871            77,
27872            ClientRole::Operator,
27873            NodeRequest::CreateWorkspaceFile {
27874                workspace_id: workspace_id.clone(),
27875                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27876            },
27877        )
27878        .await
27879        .unwrap_err();
27880        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27881        let non_directory_parent = process_request_inner(
27882            &shared,
27883            77,
27884            ClientRole::Operator,
27885            NodeRequest::CreateWorkspaceFile {
27886                workspace_id,
27887                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27888            },
27889        )
27890        .await
27891        .unwrap_err();
27892        assert_eq!(
27893            non_directory_parent.code,
27894            NodeFailureCode::RepositoryParentNotDirectory,
27895        );
27896        std::fs::remove_dir_all(root).unwrap();
27897    }
27898
27899    #[tokio::test]
27900    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27901        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27902        std::fs::create_dir_all(root.join("src")).unwrap();
27903        #[cfg(windows)]
27904        let root = std::fs::canonicalize(root).unwrap();
27905        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27906        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27907        let barrier = Arc::new(std::sync::Barrier::new(2));
27908        let done = Arc::new(AtomicBool::new(false));
27909        let task_commit_state = Arc::clone(&commit_state);
27910        let task_barrier = Arc::clone(&barrier);
27911        let task_done = Arc::clone(&done);
27912        let task_root = root.clone();
27913        let task = tokio::task::spawn_blocking(move || {
27914            task_barrier.wait();
27915            let result = create_workspace_file_on_disk(
27916                &task_root,
27917                &path,
27918                &task_commit_state,
27919            );
27920            task_done.store(true, Ordering::Release);
27921            result
27922        });
27923
27924        let error = settle_workspace_entry_create(
27925            task,
27926            Arc::clone(&commit_state),
27927            Duration::from_millis(10),
27928            "fixture deadline",
27929            "fixture task failed",
27930        )
27931        .await
27932        .unwrap_err();
27933        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27934        assert_eq!(
27935            commit_state.load(Ordering::Acquire),
27936            WORKSPACE_ENTRY_CREATE_CANCELED,
27937        );
27938
27939        barrier.wait();
27940        timeout(Duration::from_secs(1), async {
27941            while !done.load(Ordering::Acquire) {
27942                tokio::task::yield_now().await;
27943            }
27944        })
27945        .await
27946        .expect("canceled blocking create did not settle");
27947        assert!(!root.join("src/late.rs").exists());
27948        std::fs::remove_dir_all(root).unwrap();
27949    }
27950}
27951
27952#[cfg(test)]
27953mod provisional_spawn_tests {
27954    use super::*;
27955    use std::sync::Arc;
27956
27957    fn shared() -> NodeShared {
27958        let catalog = active_registry().unwrap();
27959        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27960        let workspace = WorkspaceConfig::new(
27961            WorkspaceId::new("primary").unwrap(),
27962            std::env::current_dir().unwrap(),
27963        )
27964        .unwrap()
27965        .with_worktree_service_mode(WorktreeServiceMode::Manual);
27966        NodeShared::new(
27967            handle,
27968            "fixture-token".to_owned(),
27969            NodeId::new("node-provisional-test").unwrap(),
27970            vec![workspace],
27971            vec![AgentId::new("claude").unwrap()],
27972        )
27973    }
27974
27975    /// Provisional draft map: sessionize by instance id only; 50 sequential
27976    /// mark/claim cycles never collide, and a claimed draft is never reaped.
27977    #[test]
27978    fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
27979        let shared = shared();
27980        let ws = WorkspaceId::new("primary").unwrap();
27981        let mut ids = Vec::with_capacity(50);
27982        for i in 1u64..=50 {
27983            let id = AgentInstanceId(i);
27984            ids.push(id);
27985            let address = SessionAddress {
27986                workspace_id: ws.clone(),
27987                session: SessionKey {
27988                    instance_id: id,
27989                    generation: SessionGeneration(1),
27990                },
27991            };
27992            shared.mark_spawn_provisional(address);
27993        }
27994        assert_eq!(
27995            shared
27996                .provisional_spawns
27997                .lock()
27998                .unwrap_or_else(|p| p.into_inner())
27999                .len(),
28000            50
28001        );
28002        for id in ids.iter().step_by(2) {
28003            shared.sessionize_spawn(*id);
28004        }
28005        assert_eq!(
28006            shared
28007                .provisional_spawns
28008                .lock()
28009                .unwrap_or_else(|p| p.into_inner())
28010                .len(),
28011            25
28012        );
28013        assert!(shared.take_due_provisional_spawns().is_empty());
28014        {
28015            let mut map = shared
28016                .provisional_spawns
28017                .lock()
28018                .unwrap_or_else(|p| p.into_inner());
28019            let past = Instant::now() - Duration::from_secs(30);
28020            for (_, (_, at)) in map.iter_mut() {
28021                *at = past;
28022            }
28023        }
28024        let due = shared.take_due_provisional_spawns();
28025        assert_eq!(due.len(), 25);
28026        let due_ids: std::collections::BTreeSet<_> =
28027            due.iter().map(|a| a.session.instance_id.0).collect();
28028        let expected: std::collections::BTreeSet<_> =
28029            ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28030        assert_eq!(due_ids, expected);
28031        assert!(shared
28032            .provisional_spawns
28033            .lock()
28034            .unwrap_or_else(|p| p.into_inner())
28035            .is_empty());
28036    }
28037
28038    /// Twenty parallel provisional marks must each get their own instance key;
28039    /// killing/sessionizing one id must not touch the others.
28040    #[test]
28041    fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28042        let shared = Arc::new(shared());
28043        let ws = WorkspaceId::new("primary").unwrap();
28044        let mut handles = Vec::new();
28045        for i in 1u64..=20 {
28046            let shared = Arc::clone(&shared);
28047            let ws = ws.clone();
28048            handles.push(std::thread::spawn(move || {
28049                let id = AgentInstanceId(i);
28050                let address = SessionAddress {
28051                    workspace_id: ws,
28052                    session: SessionKey {
28053                        instance_id: id,
28054                        generation: SessionGeneration(1),
28055                    },
28056                };
28057                shared.mark_spawn_provisional(address);
28058                id
28059            }));
28060        }
28061        let mut ids = Vec::new();
28062        for h in handles {
28063            ids.push(h.join().unwrap());
28064        }
28065        ids.sort_by_key(|id| id.0);
28066        assert_eq!(ids.len(), 20);
28067        ids.dedup();
28068        assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28069        assert_eq!(
28070            shared
28071                .provisional_spawns
28072                .lock()
28073                .unwrap_or_else(|p| p.into_inner())
28074                .len(),
28075            20
28076        );
28077        shared.sessionize_spawn(AgentInstanceId(7));
28078        let left: Vec<_> = shared
28079            .provisional_spawns
28080            .lock()
28081            .unwrap_or_else(|p| p.into_inner())
28082            .keys()
28083            .map(|id| id.0)
28084            .collect();
28085        assert!(!left.contains(&7));
28086        assert_eq!(left.len(), 19);
28087    }
28088}
28089
28090
28091#[cfg(unix)]
28092extern "C" {
28093    #[link_name = "geteuid"]
28094    fn libc_geteuid() -> u32;
28095}
28096
28097#[cfg(test)]
28098mod call_home_link_tests {
28099    use super::*;
28100
28101    #[test]
28102    fn call_home_backoff_is_capped_and_monotonic() {
28103        let mut last = Duration::ZERO;
28104        for attempt in 1..20 {
28105            let next = call_home_backoff(attempt);
28106            assert!(next >= last);
28107            assert!(next <= Duration::from_secs(8));
28108            last = next;
28109        }
28110        assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28111    }
28112
28113    #[tokio::test]
28114    async fn call_home_socket_gets_keepalive() {
28115        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28116        let addr = listener.local_addr().unwrap();
28117        let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28118        let client = client.unwrap();
28119        enable_call_home_keepalive(&client).unwrap();
28120        let sock = socket2::SockRef::from(&client);
28121        assert!(sock.keepalive().unwrap());
28122        #[cfg(any(target_os = "linux", target_os = "macos"))]
28123        assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28124    }
28125}