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                        Some(merged)
3990                    }
3991                    Err(error) => {
3992                        tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3993                        environment_overlay
3994                    }
3995                }
3996            }
3997            (SessionMode::Pty, Some(_), None, _) => {
3998                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
3999                environment_overlay
4000            }
4001            _ => environment_overlay,
4002        };
4003        let mut instance_overlay = environment_overlay
4004            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4005            .transpose()?
4006            .flatten();
4007        Ok(PreparedSpawn {
4008            instance_overlay,
4009            environment_selection,
4010            materialization_guard,
4011            harness_mcp_overlay,
4012            #[cfg(feature = "dig2-station-probe")]
4013            browser_station_lease_guard,
4014            materialization_id,
4015        })
4016    }
4017}
4018
4019struct SessionRecords {
4020    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4021}
4022
4023struct SpawnIdempotencyCache {
4024    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4025}
4026
4027struct SpawnIdempotencyEntry {
4028    value: SpawnIdempotencyValue,
4029    expires_at: Instant,
4030}
4031
4032enum SpawnIdempotencyValue {
4033    Standard {
4034        spec: SpawnSpec,
4035        result: Result<ResolvedSpawnReceipt, NodeFailure>,
4036    },
4037    Managed {
4038        request: ManagedWorktreeSpawnRequest,
4039        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4040    },
4041    HarnessMcp,
4042}
4043
4044enum DurableManagedSpawnReplayDecision {
4045    Reserved,
4046    PendingLinked,
4047    Committed(ManagedWorktreeSpawnReceipt),
4048}
4049
4050impl SpawnIdempotencyValue {
4051    fn references_context(&self, context_id: &SpawnContextId) -> bool {
4052        match self {
4053            Self::Standard { result, .. } => result
4054                .as_ref()
4055                .ok()
4056                .and_then(|receipt| receipt.context.as_ref()),
4057            Self::Managed { result, .. } => result
4058                .as_ref()
4059                .ok()
4060                .and_then(|receipt| receipt.spawn.context.as_ref()),
4061            Self::HarnessMcp => None,
4062        }
4063        .is_some_and(|receipt| &receipt.id == context_id)
4064    }
4065}
4066
4067#[derive(Clone, Copy)]
4068struct ControllerLease {
4069    connection_id: u64,
4070    expires_at: Instant,
4071}
4072
4073struct NodeEventHistory {
4074    last_sequence: u64,
4075    replay_floor_sequence: u64,
4076    events: VecDeque<NodeEventEnvelope>,
4077    record_providers: BTreeMap<SessionRecordId, AgentId>,
4078    removed_record_providers: BTreeMap<u64, AgentId>,
4079}
4080
4081impl NodeEventHistory {
4082    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4083        Self {
4084            last_sequence: 0,
4085            replay_floor_sequence: 1,
4086            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4087            record_providers,
4088            removed_record_providers: BTreeMap::new(),
4089        }
4090    }
4091}
4092
4093impl NodeShared {
4094    /// A node-spawned grok PTY session gets its own leader socket, so its agent backend runs
4095    /// as a descendant of this session's process (with the session's own environment) and
4096    /// never attaches to an operator's shared leader. Same grok home, same login.
4097    fn session_grok_leader_socket(
4098        &self,
4099        provider: &AgentId,
4100        mode: SessionMode,
4101        instance_id: AgentInstanceId,
4102    ) -> Option<std::path::PathBuf> {
4103        if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4104            return None;
4105        }
4106        let root = self
4107            .m4a_accounts
4108            .as_ref()
4109            .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4110            .unwrap_or_else(std::env::temp_dir);
4111        let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4112        if let Some(previous) = self
4113            .grok_leaders
4114            .lock()
4115            .unwrap_or_else(|p| p.into_inner())
4116            .insert(instance_id, socket.clone())
4117        {
4118            crate::grok_leader::retire(&previous);
4119        }
4120        Some(socket)
4121    }
4122
4123    /// Stops this session's own grok leader, if it had one. Never touches any other leader.
4124    fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4125        let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4126        if let Some(socket) = socket {
4127            crate::grok_leader::retire(&socket);
4128        }
4129    }
4130
4131    fn new_with_incarnation(
4132        handle: Gate4AgentHandle,
4133        access_token: String,
4134        node_id: NodeId,
4135        incarnation_id: NodeIncarnationId,
4136        workspaces: Vec<WorkspaceConfig>,
4137        enabled_providers: Vec<AgentId>,
4138        provider_runtime_statuses: ProviderRuntimeStatuses,
4139        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4140        provider_contracts: Vec<ProviderContractSupport>,
4141        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4142        spawn_profiles: SpawnProfileRegistry,
4143        native_launch_profile_control: Option<NativeLaunchProfileControl>,
4144        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4145        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4146        state_path: Option<PathBuf>,
4147        records: Vec<ManagedSessionRecord>,
4148        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4149        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4150        materializations: Vec<MaterializationOwnershipRecord>,
4151        persistence_warning: Option<String>,
4152        input_settle_timeout_ms: u64,
4153    ) -> Self {
4154        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4155        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4156        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4157        let (harness_mcp_proxy_event_tx, _) =
4158            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4159        let record_providers = records
4160            .iter()
4161            .map(|record| (record.record_id.clone(), record.provider.clone()))
4162            .collect();
4163        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4164            .iter()
4165            .map(|workspace| {
4166                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4167            })
4168            .collect();
4169        for lease in &managed_worktrees {
4170            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4171        }
4172        let managed_worktree_profiles = workspaces.iter()
4173            .map(|workspace| (
4174                workspace.workspace_id.clone(),
4175                workspace.managed_worktree_profiles.clone(),
4176            ))
4177            .collect();
4178        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4179            managed_worktrees,
4180            managed_worktree_tombstones,
4181        ).expect("durable managed worktree registry was validated while loading");
4182        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4183        managed_worktrees.reattach_record_holders(
4184            &managed_worktree_record_holders(&records),
4185            unix_time_ms(),
4186        );
4187        Self {
4188            handle,
4189            access_token,
4190            node_id,
4191            incarnation_id,
4192            started_at_unix_ms: std::time::SystemTime::now()
4193                .duration_since(std::time::UNIX_EPOCH)
4194                .unwrap_or_default()
4195                .as_millis()
4196                .min(u64::MAX as u128) as u64,
4197            workspaces: RwLock::new(
4198                workspaces
4199                    .into_iter()
4200                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4201                    .collect(),
4202            ),
4203            worktree_service_modes,
4204            managed_worktree_profiles,
4205            managed_worktrees: Mutex::new(managed_worktrees),
4206            enabled_providers,
4207            provider_runtime_statuses,
4208            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4209            provider_runtime_monitor,
4210            provider_contracts,
4211            provider_adapter_contracts,
4212            spawn_profiles,
4213            environment_profiles: RwLock::new(BTreeMap::new()),
4214            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4215            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4216            #[cfg(feature = "dig2-station-probe")]
4217            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4218            bundle_catalog: RwLock::new(BundleCatalog::default()),
4219            delivery_store: Mutex::new(None),
4220            context_pack_store: Mutex::new(None),
4221            context_catalog: RwLock::new(ContextPackCatalog::default()),
4222            native_launch_profile_control,
4223            harness_mcp_registry: None,
4224            native_session_catalog,
4225            session_environment_materializer,
4226            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4227            m4a_live: Mutex::new(BTreeMap::new()),
4228            grok_leaders: Mutex::new(BTreeMap::new()),
4229            #[cfg(unix)]
4230            signer_proxy: std::sync::OnceLock::new(),
4231            materializations: Mutex::new(
4232                materializations
4233                    .into_iter()
4234                    .map(|record| (record.id().clone(), record))
4235                    .collect(),
4236            ),
4237            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4238                entries: BTreeMap::new(),
4239            }),
4240            managed_spawn_replays: Mutex::new(BTreeMap::new()),
4241            controller: Mutex::new(None),
4242            provisional_spawns: Mutex::new(BTreeMap::new()),
4243            pending_acp_options: Mutex::new(BTreeMap::new()),
4244            history: Mutex::new(NodeEventHistory::new(record_providers)),
4245            event_tx,
4246            terminal_event_tx,
4247            agent_stream_event_tx,
4248            harness_mcp_proxy_event_tx,
4249            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4250            pty_input_wake: tokio::sync::Notify::new(),
4251            pty_input_marks: Mutex::new(BTreeMap::new()),
4252            next_connection_id: AtomicU64::new(1),
4253            next_instance_id: AtomicU64::new(1),
4254            next_command_id: AtomicU64::new(1),
4255            shutdown: AtomicBool::new(false),
4256            shutdown_notify: Notify::new(),
4257            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4258            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4259            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4260            mutation_gate: AsyncMutex::new(()),
4261            session_bindings: Mutex::new(BTreeMap::new()),
4262            session_records: Mutex::new(SessionRecords {
4263                records: records
4264                    .into_iter()
4265                    .map(|record| (record.record_id.clone(), record))
4266                    .collect(),
4267            }),
4268            state_path,
4269            persistence_error: RwLock::new(
4270                persistence_warning
4271                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
4272            ),
4273            state_transaction: Mutex::new(()),
4274            input_settle_timeout_ms,
4275            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4276            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4277            shell_efficiency_profile: OnceLock::new(),
4278            native_session_gauge: AtomicUsize::new(0),
4279            connection_loop_iterations: AtomicU64::new(0),
4280            connection_events_sent: AtomicU64::new(0),
4281            rejected_commands_total: AtomicU64::new(0),
4282            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4283            drive_loop_iterations_total: AtomicU64::new(0),
4284            drive_loop_iterations_idle: AtomicU64::new(0),
4285            session_record_retention: SessionRecordRetentionConfig::default(),
4286            retired_records_total: AtomicU64::new(0),
4287            #[cfg(feature = "fixture")]
4288            fixture_semantic_hook_policy: false,
4289            #[cfg(feature = "fixture")]
4290            fixture_spawn_managed_worktree_v2_failure_probe:
4291                SpawnManagedWorktreeV2FailureProbe::default(),
4292        }
4293    }
4294
4295    #[cfg(test)]
4296    fn new(
4297        handle: Gate4AgentHandle,
4298        access_token: String,
4299        node_id: NodeId,
4300        workspaces: Vec<WorkspaceConfig>,
4301        enabled_providers: Vec<AgentId>,
4302    ) -> Self {
4303        Self::new_with_incarnation(
4304            handle,
4305            access_token,
4306            node_id,
4307            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4308            workspaces,
4309            enabled_providers,
4310            ProviderRuntimeStatuses::default(),
4311            None,
4312            Vec::new(),
4313            Vec::new(),
4314            SpawnProfileRegistry::default(),
4315            None,
4316            None,
4317            None,
4318            None,
4319            Vec::new(),
4320            Vec::new(),
4321            Vec::new(),
4322            Vec::new(),
4323            None,
4324            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4325        )
4326    }
4327
4328    fn persistence_error(&self) -> Option<String> {
4329        self.persistence_error
4330            .read()
4331            .unwrap_or_else(|poisoned| poisoned.into_inner())
4332            .clone()
4333    }
4334
4335    async fn admit_provider_runtime(
4336        &self,
4337        provider: &AgentId,
4338        requirement: ProviderRuntimeRequirement,
4339    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4340        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4341            let pipe_transport = monitor.supports_pipe_transport(provider);
4342            let acp_transport = monitor.supports_acp_transport(provider);
4343            let provider = provider.clone();
4344            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4345            let (status, admission) = timeout(
4346                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4347                refresh,
4348            )
4349            .await
4350            .map_err(|_| failure(
4351                NodeFailureCode::BackendBusy,
4352                "provider runtime probe exceeded its bounded deadline",
4353            ))?
4354            .map_err(|_| failure(
4355                NodeFailureCode::BackendOperationFailed,
4356                "provider runtime probe task failed",
4357            ))?;
4358            if let Some(status) = status {
4359                self
4360                    .provider_runtime_status_updates
4361                    .write()
4362                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4363                    .insert(status.provider().clone(), status);
4364            }
4365            admission.and_then(|policy| {
4366                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4367            })
4368        } else {
4369            // No live monitor means no catalog to consult -- there is
4370            // nothing here to confirm this provider declares a Pipe or ACP
4371            // transport, so Inline/Acp are rejected the same conservative
4372            // way an undeclared provider always was, rather than admitted
4373            // on trust.
4374            crate::provider_runtime::admit_status(
4375                &self.provider_runtime_statuses,
4376                provider,
4377                false,
4378                false,
4379                requirement,
4380            )
4381        };
4382        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4383        admission.map_err(|error| match error {
4384            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4385                NodeFailureCode::BackendOperationFailed,
4386                "provider launcher is unavailable",
4387            ),
4388            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4389                NodeFailureCode::UnsupportedCapability,
4390                "provider semantic capability is not verified",
4391            ),
4392            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4393                NodeFailureCode::BackendBusy,
4394                "provider runtime probe is already in progress",
4395            ),
4396        })
4397    }
4398
4399    /// Whether the catalog declares a Pipe transport for `provider` --
4400    /// the fact `require_policy` needs to admit or reject
4401    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4402    /// catalog to consult, so this stays conservative and rejects, matching
4403    /// `admit_provider_runtime`'s own no-monitor branch.
4404    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4405        match self.provider_runtime_monitor.as_deref() {
4406            Some(monitor) => monitor.supports_pipe_transport(provider),
4407            None => false,
4408        }
4409    }
4410
4411    /// Whether the catalog declares an ACP transport for `provider` -- the
4412    /// fact `require_policy` needs to admit or reject
4413    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4414    /// `provider_supports_pipe_transport` exactly, including the
4415    /// conservative no-monitor default.
4416    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4417        match self.provider_runtime_monitor.as_deref() {
4418            Some(monitor) => monitor.supports_acp_transport(provider),
4419            None => false,
4420        }
4421    }
4422
4423    /// Grants hook ingestion to a provider whose catalog entry declares a
4424    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4425    ///
4426    /// `admit_status`'s `RawPassthrough` arm hands back
4427    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4428    /// correct for everything it was written to answer, and wrong for hooks
4429    /// specifically. A hook event is not inferred from terminal behaviour,
4430    /// so it has nothing to do with whether a launcher probe verified this
4431    /// CLI version's PTY contract; the authority is the catalog declaring
4432    /// the adapter plus the ingress authenticating the route. Without this
4433    /// the node installs a provider's hooks, the CLI faithfully calls them,
4434    /// and every event is refused -- which is exactly the state grok was in.
4435    ///
4436    /// Applied to both admission paths so the monitor's own derivation and
4437    /// this coarse fallback cannot disagree about the same provider.
4438    fn admit_declared_hook_semantics(
4439        &self,
4440        provider: &AgentId,
4441        policy: ProviderRuntimePolicy,
4442    ) -> ProviderRuntimePolicy {
4443        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4444            return policy;
4445        }
4446        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4447            contract.provider == *provider && contract.family == AdapterFamily::Hook
4448        });
4449        if !declares_hook {
4450            return policy;
4451        }
4452        ProviderRuntimePolicy::new(
4453            policy.raw_pty_lifecycle,
4454            policy.semantic_readiness,
4455            policy.structured_prompt,
4456            policy.provider_session_identity,
4457            policy.semantic_resume,
4458            true,
4459        )
4460        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4461    }
4462
4463    /// Station network allowlist → provider launch overlay argv.
4464    ///
4465    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4466    ///   `provider_native` under Moderate (PTY or ACP).
4467    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4468    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4469    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4470    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4471    ///   no overlay.
4472    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4473    ///   empty overlay when no `provider_native`; invented native keys already
4474    ///   refuse at load / resolve.
4475    ///
4476    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4477    fn resolve_network_allowlist_overlay_args(
4478        &self,
4479        provider: &AgentId,
4480        mode: SessionMode,
4481        approval_level: ApprovalLevel,
4482        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4483        provider_home: Option<&Path>,
4484    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4485        let Some(allowlist_id) = network_allowlist else {
4486            return Ok(Vec::new());
4487        };
4488        let catalog = self
4489            .network_allowlist_catalog
4490            .read()
4491            .unwrap_or_else(|poisoned| poisoned.into_inner());
4492        let Some(entry) = catalog.get(allowlist_id) else {
4493            return Err(failure(
4494                NodeFailureCode::UnknownNetworkAllowlist,
4495                "spawn network allowlist is unavailable on this node",
4496            ));
4497        };
4498
4499        // Claude: settings-shaped overlay from permits (not provider_native).
4500        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4501            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4502                |message| {
4503                    failure(
4504                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4505                        &message,
4506                    )
4507                },
4508            )?;
4509            if !matches!(mode, SessionMode::Pty) {
4510                return Err(failure(
4511                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4512                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4513                ));
4514            }
4515            let Some(home) = provider_home else {
4516                return Err(failure(
4517                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4518                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4519                ));
4520            };
4521            let domains =
4522                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4523            if domains.is_empty() {
4524                return Err(failure(
4525                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4526                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4527                ));
4528            }
4529            let settings_path = home.join(
4530                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4531            );
4532            let json =
4533                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4534            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4535                |_| {
4536                    failure(
4537                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4538                        "Claude station network settings sidecar could not be written under ProviderHome",
4539                    )
4540                },
4541            )?;
4542            let overlay =
4543                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4544            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4545        }
4546
4547        let Some(native) = entry.provider_native.as_ref() else {
4548            return Ok(Vec::new());
4549        };
4550        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4551            provider.as_str(),
4552            approval_level,
4553            native,
4554        )
4555        .map_err(|message| {
4556            failure(
4557                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4558                &message,
4559            )
4560        })?;
4561        if overlay.is_empty() {
4562            return Ok(Vec::new());
4563        }
4564        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4565            return Err(failure(
4566                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4567                "Codex network_access -c overlay requires PTY or ACP transport",
4568            ));
4569        }
4570        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4571    }
4572
4573    /// Backward-compatible alias used by Codex-focused unit tests.
4574    fn resolve_codex_network_access_overlay_args(
4575        &self,
4576        provider: &AgentId,
4577        mode: SessionMode,
4578        approval_level: ApprovalLevel,
4579        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4580    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4581        self.resolve_network_allowlist_overlay_args(
4582            provider,
4583            mode,
4584            approval_level,
4585            network_allowlist,
4586            None,
4587        )
4588    }
4589
4590    fn resolve_environment_profile(
4591        &self,
4592        resolved: &ResolvedSpawnSpec,
4593    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4594        // Station network allowlist catalog enforce (plan
4595        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4596        // catalog — unknown ids refuse rather than silent ambient. Runs even
4597        // when no environment_profile_id is present so a bare allowlist still
4598        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4599        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4600            let catalog = self
4601                .network_allowlist_catalog
4602                .read()
4603                .unwrap_or_else(|poisoned| poisoned.into_inner());
4604            let Some(entry) = catalog.get(allowlist_id) else {
4605                return Err(failure(
4606                    NodeFailureCode::UnknownNetworkAllowlist,
4607                    "spawn network allowlist is unavailable on this node",
4608                ));
4609            };
4610            // Track B: refuse provider-native mappings this provider cannot
4611            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4612            if let Some(native) = entry.provider_native.as_ref() {
4613                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4614                    resolved.provider.as_str(),
4615                    resolved.approval_level,
4616                    native,
4617                ) {
4618                    return Err(failure(
4619                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4620                        &message,
4621                    ));
4622                }
4623            }
4624        }
4625        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4626        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4627        // cheap named-pipe path reachability must succeed (Windows
4628        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4629        // NodeFailureCode. Runs even without environment_profile_id (same
4630        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4631        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4632        // acquired later in spawn_session_with_deadline (after instance id).
4633        #[cfg(feature = "dig2-station-probe")]
4634        if resolved.browser_profile_id.is_some() {
4635            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4636            match probe_station_reachable() {
4637                Ok(()) => {}
4638                Err(StationProbeError::UnsupportedPlatform)
4639                | Err(StationProbeError::InvalidPipeSuffix) => {
4640                    return Err(failure(
4641                        NodeFailureCode::BrowserStationProbeUnavailable,
4642                        "dig2browser station probe unavailable on this platform",
4643                    ));
4644                }
4645                Err(StationProbeError::Unreachable) => {
4646                    return Err(failure(
4647                        NodeFailureCode::BrowserStationUnreachable,
4648                        "dig2browser station named-pipe is unreachable",
4649                    ));
4650                }
4651            }
4652        }
4653        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4654            return Ok(None);
4655        };
4656        let profiles = self
4657            .environment_profiles
4658            .read()
4659            .unwrap_or_else(|poisoned| poisoned.into_inner());
4660        let binding = profiles.get(profile_id).ok_or_else(|| {
4661            failure(
4662                NodeFailureCode::UnknownEnvironmentProfile,
4663                "spawn environment profile is unavailable on this node",
4664            )
4665        })?;
4666        if binding.provider != resolved.provider
4667            || binding.native_profile_id(resolved.mode).is_none()
4668        {
4669            return Err(failure(
4670                NodeFailureCode::EnvironmentProfileBindingMismatch,
4671                "spawn environment profile does not match the provider and mode",
4672            ));
4673        }
4674        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4675        // after catalog + optional dig2 probe above. Empty/whitespace ids
4676        // already refuse at Spawn*Id construction.
4677        Ok(Some(ResolvedEnvironmentProfileReceipt {
4678            profile_id: binding.id.clone(),
4679            profile_revision: binding.revision.clone(),
4680            network_allowlist: resolved.network_allowlist.clone(),
4681            browser_profile_id: resolved.browser_profile_id.clone(),
4682        }))
4683    }
4684
4685    fn resolve_bundle(
4686        &self,
4687        resolved: &ResolvedSpawnSpec,
4688        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4689    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4690        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4691            return Ok(None);
4692        };
4693        let catalog = self.bundle_catalog
4694            .read()
4695            .unwrap_or_else(|poisoned| poisoned.into_inner());
4696        let bundle = catalog.get(bundle_id).ok_or_else(|| {
4697                failure(
4698                    NodeFailureCode::UnknownBundle,
4699                    "spawn bundle is unavailable on this node",
4700                )
4701            })?;
4702        self.bundle_layout(
4703            &resolved.provider,
4704            resolved.mode,
4705            environment_profile,
4706            bundle,
4707        )?;
4708        Ok(Some(bundle.receipt()))
4709    }
4710
4711    fn begin_delivery_stage(
4712        &self,
4713        manifest: DeliveryBundleManifestV2,
4714    ) -> Result<NodeResponse, NodeFailure> {
4715        let mut store = self
4716            .delivery_store
4717            .lock()
4718            .unwrap_or_else(|poisoned| poisoned.into_inner());
4719        let store = store.as_mut().ok_or_else(|| {
4720            failure(
4721                NodeFailureCode::DeliveryStageStorageFailed,
4722                "node delivery store is unavailable",
4723            )
4724        })?;
4725        let begun = store.begin(manifest).map_err(delivery_failure)?;
4726        Ok(NodeResponse::DeliveryStageBegun {
4727            stage_id: begun.stage_id,
4728            manifest_digest: begun.manifest_digest,
4729            missing_blobs: begun.missing_blobs,
4730        })
4731    }
4732
4733    fn put_delivery_blob_chunk(
4734        &self,
4735        stage_id: DeliveryStageId,
4736        blob_digest: DeliveryBlobDigestV1,
4737        offset: u64,
4738        chunk_hex: DeliveryBlobChunkHexV1,
4739    ) -> Result<NodeResponse, NodeFailure> {
4740        let mut store = self
4741            .delivery_store
4742            .lock()
4743            .unwrap_or_else(|poisoned| poisoned.into_inner());
4744        let store = store.as_mut().ok_or_else(|| {
4745            failure(
4746                NodeFailureCode::DeliveryStageStorageFailed,
4747                "node delivery store is unavailable",
4748            )
4749        })?;
4750        let accepted = store
4751            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4752            .map_err(delivery_failure)?;
4753        Ok(NodeResponse::DeliveryBlobChunkAccepted {
4754            stage_id: accepted.stage_id,
4755            blob_digest: accepted.blob_digest,
4756            next_offset: accepted.next_offset,
4757        })
4758    }
4759
4760    fn commit_delivery_stage(
4761        &self,
4762        stage_id: DeliveryStageId,
4763    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4764        let mut store = self
4765            .delivery_store
4766            .lock()
4767            .unwrap_or_else(|poisoned| poisoned.into_inner());
4768        let store = store.as_mut().ok_or_else(|| {
4769            failure(
4770                NodeFailureCode::DeliveryStageStorageFailed,
4771                "node delivery store is unavailable",
4772            )
4773        })?;
4774        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4775        let mut catalog = self
4776            .bundle_catalog
4777            .write()
4778            .unwrap_or_else(|poisoned| poisoned.into_inner());
4779        let mut next_catalog = catalog.clone();
4780        next_catalog
4781            .insert_idempotent(prepared.bundle().clone())
4782            .map_err(|_| {
4783                failure(
4784                    NodeFailureCode::DeliveryStageConflict,
4785                    "delivery bundle identity conflicts with installed content",
4786                )
4787            })?;
4788        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4789        *catalog = next_catalog;
4790        Ok(receipt)
4791    }
4792
4793    fn abort_delivery_stage(
4794        &self,
4795        stage_id: &DeliveryStageId,
4796    ) -> Result<(), NodeFailure> {
4797        let mut store = self
4798            .delivery_store
4799            .lock()
4800            .unwrap_or_else(|poisoned| poisoned.into_inner());
4801        let store = store.as_mut().ok_or_else(|| {
4802            failure(
4803                NodeFailureCode::DeliveryStageStorageFailed,
4804                "node delivery store is unavailable",
4805            )
4806        })?;
4807        store.abort(stage_id).map_err(delivery_failure)
4808    }
4809
4810    fn resolve_context(
4811        &self,
4812        resolved: &ResolvedSpawnSpec,
4813    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4814        let Some(context_id) = resolved.context_id.as_ref() else {
4815            return Ok(None);
4816        };
4817        self.context_catalog
4818            .read()
4819            .unwrap_or_else(|poisoned| poisoned.into_inner())
4820            .get(context_id)
4821            .map(|context| context.receipt().clone())
4822            .ok_or_else(|| {
4823                failure(
4824                    NodeFailureCode::UnknownContextPack,
4825                    "spawn context pack is unavailable on this node",
4826                )
4827            })
4828            .map(Some)
4829    }
4830
4831    fn bundle_layout(
4832        &self,
4833        provider: &AgentId,
4834        mode: SessionMode,
4835        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4836        bundle: &NodeBundle,
4837    ) -> Result<BundleProviderLayout, NodeFailure> {
4838        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4839            failure(
4840                NodeFailureCode::BundleBindingMismatch,
4841                "spawn bundle does not match the provider and mode",
4842            )
4843        })?;
4844        if layout != BundleProviderLayout::Codex {
4845            return Ok(layout);
4846        }
4847        let profile = environment_profile.and_then(|receipt| {
4848            let profiles = self
4849                .environment_profiles
4850                .read()
4851                .unwrap_or_else(|poisoned| poisoned.into_inner());
4852            let binding = profiles.get(&receipt.profile_id)?;
4853            if binding.revision != receipt.profile_revision
4854                || &binding.provider != provider
4855                || binding.native_profile_id(mode).is_none()
4856            {
4857                return None;
4858            }
4859            self.environment_materialization_profiles
4860                .read()
4861                .unwrap_or_else(|poisoned| poisoned.into_inner())
4862                .get(&receipt.profile_id)
4863                .cloned()
4864        });
4865        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4866            return Err(failure(
4867                NodeFailureCode::BundleBindingMismatch,
4868                "Codex bundle requires an exact isolated CODEX_HOME profile",
4869            ));
4870        }
4871        Ok(layout)
4872    }
4873
4874    fn select_environment_profile(
4875        &self,
4876        instance_id: AgentInstanceId,
4877        provider: &AgentId,
4878        mode: SessionMode,
4879        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4880    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4881        let Some(environment_profile) = environment_profile else {
4882            return Ok(None);
4883        };
4884        let native_profile_id = {
4885            let profiles = self
4886                .environment_profiles
4887                .read()
4888                .unwrap_or_else(|poisoned| poisoned.into_inner());
4889            let binding = profiles
4890                .get(&environment_profile.profile_id)
4891                .ok_or_else(|| {
4892                    failure(
4893                        NodeFailureCode::UnknownEnvironmentProfile,
4894                        "session environment profile is unavailable on this node",
4895                    )
4896                })?;
4897            if &binding.revision != &environment_profile.profile_revision
4898                || &binding.provider != provider
4899            {
4900                return Err(failure(
4901                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4902                    "session environment profile revision or provider changed",
4903                ));
4904            }
4905            binding.native_profile_id(mode).cloned().ok_or_else(|| {
4906                failure(
4907                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4908                    "session environment profile does not support this mode",
4909                )
4910            })?
4911        };
4912        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4913            failure(
4914                NodeFailureCode::BackendOperationFailed,
4915                "native environment profile controller is unavailable",
4916            )
4917        })?;
4918        control
4919            .select_native_launch_profile(instance_id, native_profile_id)
4920            .map_err(|_| {
4921                failure(
4922                    NodeFailureCode::BackendOperationFailed,
4923                    "native environment profile selection failed",
4924                )
4925            })?;
4926        Ok(Some(NativeEnvironmentSelectionGuard {
4927            control: Some(control),
4928            instance_id,
4929        }))
4930    }
4931
4932    fn install_prepared_launch_overlay(
4933        &self,
4934        instance_id: AgentInstanceId,
4935        overlay: PreparedNativeLaunchOverlay,
4936    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4937        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4938            failure(
4939                NodeFailureCode::BackendOperationFailed,
4940                "native launch profile controller is unavailable",
4941            )
4942        })?;
4943        match overlay {
4944            PreparedNativeLaunchOverlay::Environment(overlay) => {
4945                control
4946                    .install_native_launch_environment_overlay(instance_id, overlay)
4947                    .map_err(|_| failure(
4948                        NodeFailureCode::BackendOperationFailed,
4949                        "session environment overlay installation failed",
4950                    ))?;
4951                Ok(None)
4952            }
4953            PreparedNativeLaunchOverlay::Instance(overlay) => {
4954                control
4955                    .install_native_instance_launch_overlay(instance_id, overlay)
4956                    .map_err(|_| failure(
4957                        NodeFailureCode::BundleBindingMismatch,
4958                        "session bundle launch overlay installation failed",
4959                    ))?;
4960                Ok(Some(NativeInstanceOverlayGuard {
4961                    control: Some(control),
4962                    instance_id,
4963                }))
4964            }
4965        }
4966    }
4967
4968    fn environment_materialization_profile(
4969        &self,
4970        environment_profile: &ResolvedEnvironmentProfileReceipt,
4971    ) -> Option<NodeSessionMaterializationProfile> {
4972        self.environment_materialization_profiles
4973            .read()
4974            .unwrap_or_else(|poisoned| poisoned.into_inner())
4975            .get(&environment_profile.profile_id)
4976            .cloned()
4977    }
4978
4979    fn materialization_profile(
4980        &self,
4981        provider: &AgentId,
4982        mode: SessionMode,
4983        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4984        bundle: Option<&ResolvedBundleReceipt>,
4985        context: Option<&ResolvedContextPackReceipt>,
4986    ) -> Result<Option<(
4987        NodeSessionMaterializationProfile,
4988        bool,
4989        Option<BundleProviderLayout>,
4990    )>, NodeFailure> {
4991        let environment = environment_profile
4992            .and_then(|receipt| self.environment_materialization_profile(receipt));
4993        let (bundle, bundle_layout) = match bundle {
4994            Some(receipt) => {
4995                let catalog = self.bundle_catalog.read()
4996                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4997                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4998                    NodeFailureCode::UnknownBundle,
4999                    "session bundle is unavailable on this node",
5000                ))?;
5001                if installed.receipt() != *receipt {
5002                    return Err(failure(
5003                        NodeFailureCode::BundleBindingMismatch,
5004                        "session bundle revision or digest changed",
5005                    ));
5006                }
5007                let layout = self.bundle_layout(
5008                    provider,
5009                    mode,
5010                    environment_profile,
5011                    installed,
5012                )?;
5013                (Some(installed.clone()), Some(layout))
5014            }
5015            None => (None, None),
5016        };
5017        let has_environment_overlay = environment.is_some() || context.is_some();
5018        let profile = match (environment, bundle.as_ref(), bundle_layout) {
5019            (None, None, None) => None,
5020            (Some(profile), None, None) => Some(Ok(profile)),
5021            (None, Some(bundle), Some(layout)) => {
5022                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5023            }
5024            (Some(profile), Some(bundle), Some(layout)) => {
5025                Some(profile.with_bundle(bundle, layout))
5026            }
5027            _ => {
5028                return Err(failure(
5029                    NodeFailureCode::BundleBindingMismatch,
5030                    "session bundle provider layout is unavailable",
5031                ));
5032            }
5033        };
5034        let profile = profile
5035            .transpose()
5036            .map_err(|_| failure(
5037                NodeFailureCode::BundleMaterializationFailed,
5038                "session bundle materialization profile is invalid",
5039            ))?;
5040        let profile = match (profile, context) {
5041            (Some(profile), Some(receipt)) => {
5042                let catalog = self.context_catalog
5043                    .read()
5044                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5045                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5046                    NodeFailureCode::UnknownContextPack,
5047                    "session context pack is unavailable on this node",
5048                ))?;
5049                if pack.receipt() != receipt {
5050                    return Err(failure(
5051                        NodeFailureCode::ContextPackMaterializationFailed,
5052                        "session context pack identity changed",
5053                    ));
5054                }
5055                profile.with_context(pack)
5056            }
5057            (None, Some(receipt)) => {
5058                let catalog = self.context_catalog
5059                    .read()
5060                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5061                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5062                    NodeFailureCode::UnknownContextPack,
5063                    "session context pack is unavailable on this node",
5064                ))?;
5065                if pack.receipt() != receipt {
5066                    return Err(failure(
5067                        NodeFailureCode::ContextPackMaterializationFailed,
5068                        "session context pack identity changed",
5069                    ));
5070                }
5071                NodeSessionMaterializationProfile::from_context(pack)
5072            }
5073            (Some(profile), None) => Ok(profile),
5074            (None, None) => return Ok(None),
5075        }
5076        .map_err(|_| failure(
5077            NodeFailureCode::ContextPackMaterializationFailed,
5078            "session context pack materialization profile is invalid",
5079        ))?;
5080        Ok(Some((profile, has_environment_overlay, bundle_layout)))
5081    }
5082
5083    fn prepare_session_materialization<'a>(
5084        &'a self,
5085        address: &SessionAddress,
5086        provider: &AgentId,
5087        mode: SessionMode,
5088        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5089        bundle: Option<&ResolvedBundleReceipt>,
5090        context: Option<&ResolvedContextPackReceipt>,
5091        managed_lease_id: Option<ManagedWorktreeLeaseId>,
5092    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5093        let Some((profile, has_environment_overlay, bundle_layout)) =
5094            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5095        else {
5096            return Ok(None);
5097        };
5098        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5099            failure(
5100                NodeFailureCode::BackendOperationFailed,
5101                "session environment materializer is unavailable",
5102            )
5103        })?;
5104        let materialization_failure_code = if context.is_some() {
5105            NodeFailureCode::ContextPackMaterializationFailed
5106        } else if bundle.is_some() {
5107            NodeFailureCode::BundleMaterializationFailed
5108        } else {
5109            NodeFailureCode::BackendOperationFailed
5110        };
5111        let id = MaterializationId::new(format!(
5112            "mat-{}-{}-{}",
5113            self.incarnation_id,
5114            address.session.instance_id.0,
5115            address.session.generation.0,
5116        ))
5117        .map_err(|_| {
5118            failure(
5119                NodeFailureCode::BackendOperationFailed,
5120                "session environment materialization identity failed",
5121            )
5122        })?;
5123        let owner = MaterializationOwner::Session {
5124            incarnation_id: self.incarnation_id,
5125            instance_id: address.session.instance_id,
5126            generation: address.session.generation,
5127        };
5128        let mut ownership = materializer
5129            .begin(
5130                id.clone(),
5131                environment_profile.cloned(),
5132                bundle.cloned(),
5133                context.cloned(),
5134                owner,
5135                managed_lease_id,
5136                &profile,
5137                unix_time_ms(),
5138            )
5139            .map_err(|_| {
5140                failure(
5141                    materialization_failure_code,
5142                    "session materialization preparation failed",
5143                )
5144            })?;
5145        {
5146            let _transaction = self
5147                .state_transaction
5148                .lock()
5149                .unwrap_or_else(|poisoned| poisoned.into_inner());
5150            let duplicate = self
5151                .materializations
5152                .lock()
5153                .unwrap_or_else(|poisoned| poisoned.into_inner())
5154                .insert(id.clone(), ownership.clone());
5155            if duplicate.is_some() {
5156                return Err(failure(
5157                    NodeFailureCode::BackendOperationFailed,
5158                    "session environment materialization identity conflict",
5159                ));
5160            }
5161            if let Err(error) = self.persist_state_locked() {
5162                self.materializations
5163                    .lock()
5164                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5165                    .remove(&id);
5166                return Err(persistence_failure(error));
5167            }
5168        }
5169        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5170            Ok(environment) => environment,
5171            Err(_) => {
5172                let transaction = self
5173                    .state_transaction
5174                    .lock()
5175                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5176                self.materializations
5177                    .lock()
5178                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5179                    .insert(id.clone(), ownership.clone());
5180                if self.persist_state_locked().is_err() {
5181                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5182                }
5183                drop(transaction);
5184                if ownership.state() == MaterializationState::CleanupRequired {
5185                    let _ = self.cleanup_materialization(&id);
5186                }
5187                return Err(failure(
5188                    materialization_failure_code,
5189                    "session materialization failed",
5190                ));
5191            }
5192        };
5193        {
5194            let _transaction = self
5195                .state_transaction
5196                .lock()
5197                .unwrap_or_else(|poisoned| poisoned.into_inner());
5198            self.materializations
5199                .lock()
5200                .unwrap_or_else(|poisoned| poisoned.into_inner())
5201                .insert(id.clone(), ownership.clone());
5202            if let Err(error) = self.persist_state_locked() {
5203                drop(_transaction);
5204                let _ = self.cleanup_materialization(&id);
5205                return Err(persistence_failure(error));
5206            }
5207        }
5208        let guard = SessionMaterializationGuard {
5209            shared: self,
5210            id: Some(id),
5211            provider_home: Some(ownership.provider_home().to_path_buf()),
5212        };
5213        let bundle_arguments = match bundle {
5214            Some(receipt) => {
5215                let catalog = self.bundle_catalog.read()
5216                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5217                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5218                    NodeFailureCode::UnknownBundle,
5219                    "session bundle is unavailable on this node",
5220                ))?;
5221                if installed.receipt() != *receipt {
5222                    return Err(failure(
5223                        NodeFailureCode::BundleBindingMismatch,
5224                        "session bundle revision or digest changed",
5225                    ));
5226                }
5227                let layout = bundle_layout.ok_or_else(|| failure(
5228                    NodeFailureCode::BundleBindingMismatch,
5229                    "materialized bundle provider layout is unavailable",
5230                ))?;
5231                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5232                    failure(
5233                        NodeFailureCode::BundleMaterializationFailed,
5234                        "session bundle byte revalidation failed",
5235                    )
5236                })?;
5237                Some(bundle_launch_arguments(
5238                    layout,
5239                    ownership.bundle_root(),
5240                    ownership.provider_home(),
5241                ).map_err(|_| failure(
5242                    NodeFailureCode::BundleBindingMismatch,
5243                    "session bundle launch binding is invalid",
5244                ))?)
5245            }
5246            None => None,
5247        };
5248        if let Some(receipt) = context {
5249            materializer
5250                .revalidate_context(&ownership, receipt)
5251                .map_err(|_| failure(
5252                    NodeFailureCode::ContextPackMaterializationFailed,
5253                    "session context pack byte revalidation failed",
5254                ))?;
5255        }
5256        let transport = match mode {
5257            SessionMode::Pty => TransportKind::Pty,
5258            SessionMode::Inline => TransportKind::Pipe,
5259            SessionMode::Acp => TransportKind::Acp,
5260        };
5261        let overlay = if let Some(extra_args) = bundle_arguments {
5262            Some(PreparedNativeLaunchOverlay::Instance(
5263                NativeInstanceLaunchOverlay::new(
5264                    provider.clone(),
5265                    transport,
5266                    if has_environment_overlay {
5267                        environment
5268                    } else {
5269                        Vec::new()
5270                    },
5271                    extra_args,
5272                )
5273                .map_err(|_| {
5274                    failure(
5275                        NodeFailureCode::BundleBindingMismatch,
5276                        "session bundle launch overlay is invalid",
5277                    )
5278                })?,
5279            ))
5280        } else if has_environment_overlay {
5281            let context_only = environment_profile.is_none()
5282                && bundle.is_none()
5283                && context.is_some();
5284            let overlay = if context_only {
5285                NativeLaunchEnvironmentOverlay::new_context_root(
5286                    provider.clone(),
5287                    transport,
5288                    environment,
5289                )
5290                .map_err(|_| {
5291                    failure(
5292                        NodeFailureCode::ContextPackMaterializationFailed,
5293                        "context-only launch overlay is invalid",
5294                    )
5295                })?
5296            } else {
5297                NativeLaunchEnvironmentOverlay::new(
5298                    provider.clone(),
5299                    transport,
5300                    environment,
5301                )
5302                .map_err(|_| {
5303                    failure(
5304                        NodeFailureCode::BackendOperationFailed,
5305                        "session environment overlay is invalid",
5306                    )
5307                })?
5308            };
5309            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5310        } else {
5311            None
5312        };
5313        Ok(Some((overlay, guard)))
5314    }
5315
5316    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5317        let Some(mut ownership) = self
5318            .materializations
5319            .lock()
5320            .unwrap_or_else(|poisoned| poisoned.into_inner())
5321            .get(id)
5322            .cloned()
5323        else {
5324            return Ok(());
5325        };
5326        if ownership.state() != MaterializationState::CleanupRequired {
5327            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5328                failure(
5329                    NodeFailureCode::BackendOperationFailed,
5330                    "session environment cleanup state is invalid",
5331                )
5332            })?;
5333        }
5334        {
5335            let _transaction = self
5336                .state_transaction
5337                .lock()
5338                .unwrap_or_else(|poisoned| poisoned.into_inner());
5339            let previous = self
5340                .materializations
5341                .lock()
5342                .unwrap_or_else(|poisoned| poisoned.into_inner())
5343                .insert(id.clone(), ownership.clone());
5344            if let Err(error) = self.persist_state_locked() {
5345                let mut materializations = self
5346                    .materializations
5347                    .lock()
5348                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5349                if let Some(previous) = previous {
5350                    materializations.insert(id.clone(), previous);
5351                }
5352                return Err(persistence_failure(error));
5353            }
5354        }
5355        let cleanup = self
5356            .session_environment_materializer
5357            .as_ref()
5358            .ok_or(())
5359            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5360        if cleanup.is_err() {
5361            let _ = ownership.mark_recovery_required(unix_time_ms());
5362            self.materializations
5363                .lock()
5364                .unwrap_or_else(|poisoned| poisoned.into_inner())
5365                .insert(id.clone(), ownership);
5366            if self.persist_state().is_err() {
5367                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5368            }
5369            return Err(failure(
5370                NodeFailureCode::BackendOperationFailed,
5371                "session environment cleanup requires recovery",
5372            ));
5373        }
5374        let _transaction = self
5375            .state_transaction
5376            .lock()
5377            .unwrap_or_else(|poisoned| poisoned.into_inner());
5378        self.materializations
5379            .lock()
5380            .unwrap_or_else(|poisoned| poisoned.into_inner())
5381            .remove(id);
5382        if let Err(error) = self.persist_state_locked() {
5383            self.materializations
5384                .lock()
5385                .unwrap_or_else(|poisoned| poisoned.into_inner())
5386                .insert(id.clone(), ownership);
5387            return Err(persistence_failure(error));
5388        }
5389        Ok(())
5390    }
5391
5392    fn resolve_record_materialization(
5393        &self,
5394        record_id: &SessionRecordId,
5395        provider: &AgentId,
5396        mode: SessionMode,
5397        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5398        bundle: Option<&ResolvedBundleReceipt>,
5399        context: Option<&ResolvedContextPackReceipt>,
5400    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5401        let ownership = {
5402            let materializations = self
5403                .materializations
5404                .lock()
5405                .unwrap_or_else(|poisoned| poisoned.into_inner());
5406            let mut matches = materializations.values().filter(|record| {
5407                record.owner()
5408                    == &MaterializationOwner::Record {
5409                        record_id: record_id.clone(),
5410                    }
5411            });
5412            let ownership = matches.next().cloned();
5413            if matches.next().is_some() {
5414                return Err(failure(
5415                    NodeFailureCode::BackendOperationFailed,
5416                    "managed session has conflicting environment ownership",
5417                ));
5418            }
5419            ownership
5420        };
5421        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5422            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5423                failure(
5424                    NodeFailureCode::ContextPackMaterializationFailed,
5425                    "session context materializer is unavailable",
5426                )
5427            })?;
5428            let pack = materializer
5429                .revalidate_context(ownership, receipt)
5430                .map_err(|_| {
5431                    self.mark_materialization_recovery_required(ownership.id());
5432                    failure(
5433                        NodeFailureCode::ContextPackMaterializationFailed,
5434                        "managed session context pack revalidation failed",
5435                    )
5436                })?;
5437            self.context_catalog
5438                .write()
5439                .unwrap_or_else(|poisoned| poisoned.into_inner())
5440                .insert(pack)
5441                .map_err(|_| failure(
5442                    NodeFailureCode::ContextPackMaterializationFailed,
5443                    "managed session context pack catalog restore failed",
5444                ))?;
5445        }
5446        let materialization = self.materialization_profile(
5447            provider,
5448            mode,
5449            environment_profile,
5450            bundle,
5451            context,
5452        )?;
5453        match (ownership, materialization) {
5454            (None, None) => Ok(None),
5455            (None, Some(_)) | (Some(_), None) => Err(failure(
5456                NodeFailureCode::BackendOperationFailed,
5457                "managed session materialization is unavailable",
5458            )),
5459            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5460                if ownership.environment_profile() != environment_profile
5461                    || ownership.bundle() != bundle
5462                    || ownership.context() != context
5463                    || ownership.state() != MaterializationState::Ready
5464                {
5465                    self.mark_materialization_recovery_required(ownership.id());
5466                    return Err(failure(
5467                        NodeFailureCode::BackendOperationFailed,
5468                        "managed session environment ownership requires recovery",
5469                    ));
5470                }
5471                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5472                    failure(
5473                        NodeFailureCode::BackendOperationFailed,
5474                        "session environment materializer is unavailable",
5475                    )
5476                })?;
5477                if materializer.revalidate(&ownership).is_err() {
5478                    self.mark_materialization_recovery_required(ownership.id());
5479                    return Err(failure(
5480                        NodeFailureCode::BackendOperationFailed,
5481                        "managed session environment revalidation failed",
5482                    ));
5483                }
5484                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5485                    let catalog = self.bundle_catalog.read()
5486                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5487                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5488                        return Err(failure(
5489                            NodeFailureCode::UnknownBundle,
5490                            "managed session bundle is unavailable",
5491                        ));
5492                    };
5493                    let layout = bundle_layout.ok_or_else(|| failure(
5494                        NodeFailureCode::BundleBindingMismatch,
5495                        "record bundle provider layout is unavailable",
5496                    ))?;
5497                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5498                        drop(catalog);
5499                        self.mark_materialization_recovery_required(ownership.id());
5500                        return Err(failure(
5501                            NodeFailureCode::BundleMaterializationFailed,
5502                            "managed session bundle revalidation failed",
5503                        ));
5504                    }
5505                    Some(bundle_launch_arguments(
5506                        layout,
5507                        ownership.bundle_root(),
5508                        ownership.provider_home(),
5509                    ).map_err(|_| failure(
5510                        NodeFailureCode::BundleBindingMismatch,
5511                        "managed session bundle launch binding is invalid",
5512                    ))?)
5513                } else {
5514                    None
5515                };
5516                if !has_environment_materialization {
5517                    let overlay = bundle_arguments.map(|extra_args| {
5518                        NativeInstanceLaunchOverlay::new(
5519                            provider.clone(),
5520                            TransportKind::Pty,
5521                            Vec::new(),
5522                            extra_args,
5523                        )
5524                        .map(PreparedNativeLaunchOverlay::Instance)
5525                        .map_err(|_| failure(
5526                            NodeFailureCode::BundleBindingMismatch,
5527                            "managed session bundle launch overlay is invalid",
5528                        ))
5529                    }).transpose()?;
5530                    return Ok(Some((ownership.id().clone(), overlay)));
5531                }
5532                let environment = match materializer.resolve_environment(&ownership, &profile) {
5533                    Ok(environment) => environment,
5534                    Err(
5535                        SessionEnvironmentMaterializeError::SecretUnavailable
5536                        | SessionEnvironmentMaterializeError::SecretDenied
5537                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5538                    ) => {
5539                        return Err(failure(
5540                            NodeFailureCode::BackendOperationFailed,
5541                            "managed session environment resolution failed",
5542                        ));
5543                    }
5544                    Err(
5545                        SessionEnvironmentMaterializeError::InvalidRoot
5546                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5547                        | SessionEnvironmentMaterializeError::Filesystem(_)
5548                        | SessionEnvironmentMaterializeError::Record(_),
5549                    ) => {
5550                        self.mark_materialization_recovery_required(ownership.id());
5551                        return Err(failure(
5552                            NodeFailureCode::BackendOperationFailed,
5553                            "managed session environment resolution failed",
5554                        ));
5555                    }
5556                };
5557                let transport = match mode {
5558                    SessionMode::Pty => TransportKind::Pty,
5559                    SessionMode::Inline => TransportKind::Pipe,
5560                    SessionMode::Acp => TransportKind::Acp,
5561                };
5562                let overlay = match bundle_arguments {
5563                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5564                        NativeInstanceLaunchOverlay::new(
5565                            provider.clone(),
5566                            transport,
5567                            environment,
5568                            extra_args,
5569                        )
5570                        .map_err(|_| failure(
5571                            NodeFailureCode::BundleBindingMismatch,
5572                            "managed session bundle launch overlay is invalid",
5573                        ))?,
5574                    ),
5575                    None => PreparedNativeLaunchOverlay::Environment(
5576                        NativeLaunchEnvironmentOverlay::new(
5577                            provider.clone(),
5578                            transport,
5579                            environment,
5580                        )
5581                        .map_err(|_| {
5582                            failure(
5583                                NodeFailureCode::BackendOperationFailed,
5584                                "managed session environment overlay is invalid",
5585                            )
5586                        })?,
5587                    ),
5588                };
5589                Ok(Some((ownership.id().clone(), Some(overlay))))
5590            }
5591        }
5592    }
5593
5594    fn destroy_record_materialization(
5595        &self,
5596        record_id: &SessionRecordId,
5597    ) -> Result<bool, NodeFailure> {
5598        let ids = self
5599            .materializations
5600            .lock()
5601            .unwrap_or_else(|poisoned| poisoned.into_inner())
5602            .values()
5603            .filter_map(|record| {
5604                (record.owner()
5605                    == &MaterializationOwner::Record {
5606                        record_id: record_id.clone(),
5607                    })
5608                .then(|| record.id().clone())
5609            })
5610            .collect::<Vec<_>>();
5611        if ids.len() > 1 {
5612            return Err(failure(
5613                NodeFailureCode::BackendOperationFailed,
5614                "managed session has conflicting environment ownership",
5615            ));
5616        }
5617        let Some(id) = ids.first().cloned() else {
5618            return Ok(false);
5619        };
5620        let (terminal_record, mut cleanup_ownership) = {
5621            let _transaction = self
5622                .state_transaction
5623                .lock()
5624                .unwrap_or_else(|poisoned| poisoned.into_inner());
5625            let original_record = self.record(record_id)?;
5626            let mut terminal_record = original_record.clone();
5627            terminal_record.provider_session = None;
5628            terminal_record.active_session = None;
5629            terminal_record.state = ManagedSessionState::Unavailable;
5630            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5631            terminal_record.updated_at_unix_ms = unix_time_ms();
5632            let original_ownership = self
5633                .materializations
5634                .lock()
5635                .unwrap_or_else(|poisoned| poisoned.into_inner())
5636                .get(&id)
5637                .cloned()
5638                .ok_or_else(|| {
5639                    failure(
5640                        NodeFailureCode::BackendOperationFailed,
5641                        "managed session environment ownership disappeared during cleanup",
5642                    )
5643                })?;
5644            let mut cleanup_ownership = original_ownership.clone();
5645            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5646                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5647                    failure(
5648                        NodeFailureCode::BackendOperationFailed,
5649                        "managed session environment cleanup state is invalid",
5650                    )
5651                })?;
5652            }
5653            self.session_records
5654                .lock()
5655                .unwrap_or_else(|poisoned| poisoned.into_inner())
5656                .records
5657                .insert(record_id.clone(), terminal_record.clone());
5658            self.materializations
5659                .lock()
5660                .unwrap_or_else(|poisoned| poisoned.into_inner())
5661                .insert(id.clone(), cleanup_ownership.clone());
5662            if let Err(error) = self.persist_state_locked() {
5663                self.session_records
5664                    .lock()
5665                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5666                    .records
5667                    .insert(record_id.clone(), original_record);
5668                self.materializations
5669                    .lock()
5670                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5671                    .insert(id.clone(), original_ownership);
5672                return Err(persistence_failure(error));
5673            }
5674            (terminal_record, cleanup_ownership)
5675        };
5676        let cleanup = self
5677            .session_environment_materializer
5678            .as_ref()
5679            .ok_or(())
5680            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5681        if cleanup.is_err() {
5682            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5683            self.materializations
5684                .lock()
5685                .unwrap_or_else(|poisoned| poisoned.into_inner())
5686                .insert(id.clone(), cleanup_ownership);
5687            if self.persist_state().is_err() {
5688                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5689            }
5690            return Err(failure(
5691                NodeFailureCode::BackendOperationFailed,
5692                "managed session environment cleanup requires recovery",
5693            ));
5694        }
5695        let _transaction = self
5696            .state_transaction
5697            .lock()
5698            .unwrap_or_else(|poisoned| poisoned.into_inner());
5699        self.remove_record_memory(record_id);
5700        self.materializations
5701            .lock()
5702            .unwrap_or_else(|poisoned| poisoned.into_inner())
5703            .remove(&id);
5704        if let Err(error) = self.persist_state_locked() {
5705            self.session_records
5706                .lock()
5707                .unwrap_or_else(|poisoned| poisoned.into_inner())
5708                .records
5709                .insert(record_id.clone(), terminal_record);
5710            self.materializations
5711                .lock()
5712                .unwrap_or_else(|poisoned| poisoned.into_inner())
5713                .insert(id, cleanup_ownership);
5714            return Err(persistence_failure(error));
5715        }
5716        Ok(true)
5717    }
5718
5719    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5720        let changed = {
5721            let mut materializations = self
5722                .materializations
5723                .lock()
5724                .unwrap_or_else(|poisoned| poisoned.into_inner());
5725            let Some(ownership) = materializations.get_mut(id) else {
5726                return;
5727            };
5728            ownership.mark_recovery_required(unix_time_ms()).is_ok()
5729        };
5730        if changed && self.persist_state().is_err() {
5731            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5732        }
5733    }
5734
5735    fn reconcile_materializations(&self) {
5736        let records = self
5737            .materializations
5738            .lock()
5739            .unwrap_or_else(|poisoned| poisoned.into_inner())
5740            .values()
5741            .cloned()
5742            .collect::<Vec<_>>();
5743        for mut ownership in records {
5744            let result = match (ownership.state(), ownership.owner()) {
5745                (MaterializationState::Preparing, _)
5746                | (MaterializationState::CleanupRequired, _) => {
5747                    self.cleanup_materialization(ownership.id())
5748                }
5749                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5750                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5751                        self.materializations
5752                            .lock()
5753                            .unwrap_or_else(|poisoned| poisoned.into_inner())
5754                            .insert(ownership.id().clone(), ownership.clone());
5755                        self.persist_state().map_err(persistence_failure)
5756                    } else {
5757                        Err(failure(
5758                            NodeFailureCode::BackendOperationFailed,
5759                            "session environment reconciliation failed",
5760                        ))
5761                    }
5762                }
5763                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5764                    .session_environment_materializer
5765                    .as_ref()
5766                    .ok_or_else(|| {
5767                        failure(
5768                            NodeFailureCode::BackendOperationFailed,
5769                            "session environment materializer is unavailable",
5770                        )
5771                    })
5772                    .and_then(|materializer| {
5773                        materializer.revalidate(&ownership).map_err(|_| failure(
5774                            NodeFailureCode::BackendOperationFailed,
5775                            "session materialization revalidation failed",
5776                        ))?;
5777                        let record = self.record(record_id)?;
5778                        if record.environment_profile.as_ref() != ownership.environment_profile()
5779                            || record.bundle.as_ref() != ownership.bundle()
5780                            || record.context.as_ref() != ownership.context()
5781                        {
5782                            return Err(failure(
5783                                NodeFailureCode::BackendOperationFailed,
5784                                "session materialization ownership changed after restart",
5785                            ));
5786                        }
5787                        if let Some(receipt) = ownership.bundle() {
5788                            let catalog = self.bundle_catalog.read()
5789                                .unwrap_or_else(|poisoned| poisoned.into_inner());
5790                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5791                                NodeFailureCode::UnknownBundle,
5792                                "session bundle is unavailable after restart",
5793                            ))?;
5794                            if bundle.receipt() != *receipt {
5795                                return Err(failure(
5796                                    NodeFailureCode::BundleBindingMismatch,
5797                                    "session bundle changed after restart",
5798                                ));
5799                            }
5800                            let layout = self.bundle_layout(
5801                                &record.provider,
5802                                record.mode,
5803                                record.environment_profile.as_ref(),
5804                                bundle,
5805                            )?;
5806                            materializer
5807                                .revalidate_bundle(&ownership, bundle, layout)
5808                                .map_err(|_| failure(
5809                                    NodeFailureCode::BundleMaterializationFailed,
5810                                    "session bundle revalidation failed",
5811                                ))?;
5812                        }
5813                        if let Some(receipt) = ownership.context() {
5814                            let pack = materializer
5815                                .revalidate_context(&ownership, receipt)
5816                                .map_err(|_| {
5817                                    failure(
5818                                        NodeFailureCode::ContextPackMaterializationFailed,
5819                                        "session context revalidation failed",
5820                                    )
5821                                })?;
5822                            self.context_catalog
5823                                .write()
5824                                .unwrap_or_else(|poisoned| poisoned.into_inner())
5825                                .insert(pack)
5826                                .map_err(|_| {
5827                                    failure(
5828                                        NodeFailureCode::ContextPackMaterializationFailed,
5829                                        "session context catalog restore failed",
5830                                    )
5831                                })?;
5832                        }
5833                        Ok(())
5834                    }),
5835                (MaterializationState::RecoveryRequired, _) => Ok(()),
5836            };
5837            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5838                let _ = ownership.mark_recovery_required(unix_time_ms());
5839                self.materializations
5840                    .lock()
5841                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5842                    .insert(ownership.id().clone(), ownership);
5843                if self.persist_state().is_err() {
5844                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5845                }
5846            }
5847        }
5848        let materialized_profiles = self
5849            .environment_materialization_profiles
5850            .read()
5851            .unwrap_or_else(|poisoned| poisoned.into_inner())
5852            .keys()
5853            .cloned()
5854            .collect::<Vec<_>>();
5855        let ownership = self
5856            .materializations
5857            .lock()
5858            .unwrap_or_else(|poisoned| poisoned.into_inner())
5859            .values()
5860            .cloned()
5861            .collect::<Vec<_>>();
5862        let changed = {
5863            let mut session_records = self
5864                .session_records
5865                .lock()
5866                .unwrap_or_else(|poisoned| poisoned.into_inner());
5867            let mut changed = false;
5868            for record in session_records.records.values_mut() {
5869                let environment_profile = record.environment_profile.as_ref();
5870                if environment_profile.is_none()
5871                    && record.bundle.is_none()
5872                    && record.context.is_none()
5873                {
5874                    continue;
5875                }
5876                if record.bundle.is_none()
5877                    && environment_profile.is_some_and(|profile| {
5878                        !materialized_profiles.contains(&profile.profile_id)
5879                    })
5880                {
5881                    continue;
5882                }
5883                let has_exact_ownership = ownership.iter().any(|candidate| {
5884                    candidate.state() == MaterializationState::Ready
5885                        && candidate.environment_profile() == environment_profile
5886                        && candidate.bundle() == record.bundle.as_ref()
5887                        && candidate.context() == record.context.as_ref()
5888                        && candidate.owner()
5889                            == &MaterializationOwner::Record {
5890                                record_id: record.record_id.clone(),
5891                            }
5892                });
5893                if !has_exact_ownership {
5894                    record.provider_session = None;
5895                    record.active_session = None;
5896                    record.state = ManagedSessionState::Unavailable;
5897                    record.last_error = Some(if record.bundle.is_some() {
5898                        "bundle-unavailable".to_owned()
5899                    } else if record.context.is_some() {
5900                        "context-unavailable".to_owned()
5901                    } else {
5902                        "environment-profile-unavailable".to_owned()
5903                    });
5904                    record.updated_at_unix_ms = unix_time_ms();
5905                    changed = true;
5906                }
5907            }
5908            changed
5909        };
5910        if changed && self.persist_state().is_err() {
5911            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5912        }
5913    }
5914
5915    fn replay_spawn_spec(
5916        &self,
5917        spec: &SpawnSpec,
5918        now: Instant,
5919    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5920        let mut cache = self
5921            .spawn_idempotency
5922            .lock()
5923            .unwrap_or_else(|poisoned| poisoned.into_inner());
5924        cache.entries.retain(|_, entry| entry.expires_at > now);
5925        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5926            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5927                return Err(failure(
5928                    NodeFailureCode::SpawnIdempotencyConflict,
5929                    "spawn idempotency key was reused across spawn kinds",
5930                ));
5931            };
5932            if existing != spec {
5933                return Err(failure(
5934                    NodeFailureCode::SpawnIdempotencyConflict,
5935                    "spawn idempotency key was reused with a different specification",
5936                ));
5937            }
5938            return Ok(Some(result.clone()));
5939        }
5940        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5941            return Err(failure(
5942                NodeFailureCode::SpawnIdempotencyCapacity,
5943                "spawn idempotency cache reached its bounded capacity",
5944            ));
5945        }
5946        Ok(None)
5947    }
5948
5949    fn remember_spawn_spec(
5950        &self,
5951        spec: SpawnSpec,
5952        result: Result<ResolvedSpawnReceipt, NodeFailure>,
5953        accepted_at: Instant,
5954    ) {
5955        self
5956            .spawn_idempotency
5957            .lock()
5958            .unwrap_or_else(|poisoned| poisoned.into_inner())
5959            .entries
5960            .insert(
5961                spec.idempotency_key.clone(),
5962                SpawnIdempotencyEntry {
5963                    value: SpawnIdempotencyValue::Standard { spec, result },
5964                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5965                },
5966            );
5967    }
5968
5969    fn claim_harness_mcp_spawn(
5970        &self,
5971        _reservation_id: &HarnessMcpReservationId,
5972        _activation_digest: &HarnessMcpActivationDigest,
5973        spec: &SpawnSpec,
5974        now: Instant,
5975    ) -> Result<(), NodeFailure> {
5976        let mut cache = self.spawn_idempotency.lock()
5977            .unwrap_or_else(|poisoned| poisoned.into_inner());
5978        cache.entries.retain(|_, entry| entry.expires_at > now);
5979        if cache.entries.contains_key(&spec.idempotency_key) {
5980            return Err(failure(
5981                NodeFailureCode::SpawnIdempotencyConflict,
5982                "spawn idempotency key was reused across spawn kinds",
5983            ));
5984        }
5985        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5986            return Err(failure(
5987                NodeFailureCode::SpawnIdempotencyCapacity,
5988                "spawn idempotency cache reached its bounded capacity",
5989            ));
5990        }
5991        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5992            value: SpawnIdempotencyValue::HarnessMcp,
5993            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5994        });
5995        Ok(())
5996    }
5997
5998    fn replay_managed_spawn(
5999        &self,
6000        request: &ManagedWorktreeSpawnRequest,
6001        now: Instant,
6002    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6003        let mut cache = self.spawn_idempotency.lock()
6004            .unwrap_or_else(|poisoned| poisoned.into_inner());
6005        cache.entries.retain(|_, entry| entry.expires_at > now);
6006        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6007            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6008                return Err(failure(
6009                    NodeFailureCode::SpawnIdempotencyConflict,
6010                    "spawn idempotency key was reused across spawn kinds",
6011                ));
6012            };
6013            if existing != request {
6014                return Err(failure(
6015                    NodeFailureCode::SpawnIdempotencyConflict,
6016                    "spawn idempotency key was reused with a different managed specification",
6017                ));
6018            }
6019            return Ok(Some(result.clone()));
6020        }
6021        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6022            return Err(failure(
6023                NodeFailureCode::SpawnIdempotencyCapacity,
6024                "spawn idempotency cache reached its bounded capacity",
6025            ));
6026        }
6027        Ok(None)
6028    }
6029
6030    fn remember_managed_spawn(
6031        &self,
6032        request: ManagedWorktreeSpawnRequest,
6033        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6034        accepted_at: Instant,
6035    ) {
6036        self.spawn_idempotency.lock()
6037            .unwrap_or_else(|poisoned| poisoned.into_inner())
6038            .entries
6039            .insert(
6040                request.spawn_spec.idempotency_key.clone(),
6041                SpawnIdempotencyEntry {
6042                    value: SpawnIdempotencyValue::Managed { request, result },
6043                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6044                },
6045            );
6046    }
6047
6048    fn remember_managed_spawn_attempt(
6049        &self,
6050        durable_v2_key: Option<&SpawnIdempotencyKey>,
6051        request: ManagedWorktreeSpawnRequest,
6052        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6053        accepted_at: Instant,
6054    ) {
6055        if durable_v2_key.is_none() {
6056            self.remember_managed_spawn(request, result, accepted_at);
6057        }
6058    }
6059
6060    fn acquire_durable_managed_spawn_v2(
6061        &self,
6062        request: &ManagedWorktreeSpawnRequestV2,
6063        request_digest: String,
6064    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6065        if self.state_path.is_none() {
6066            return Err(failure(
6067                NodeFailureCode::BackendOperationFailed,
6068                "managed worktree V2 requires durable node state",
6069            ));
6070        }
6071        let _transaction = self.state_transaction
6072            .lock()
6073            .unwrap_or_else(|poisoned| poisoned.into_inner());
6074        let key = request.spawn_spec.idempotency_key.clone();
6075        let mut previous = None;
6076        {
6077            let mut replays = self.managed_spawn_replays
6078                .lock()
6079                .unwrap_or_else(|poisoned| poisoned.into_inner());
6080            if let Some(record) = replays.get_mut(&key) {
6081                if record.request_digest != request_digest {
6082                    return Err(failure(
6083                        NodeFailureCode::SpawnIdempotencyConflict,
6084                        "managed worktree V2 idempotency key was reused with a different request",
6085                    ));
6086                }
6087                match &record.state {
6088                    ManagedWorktreeSpawnReplayStateV10::Pending {
6089                        owner_incarnation_id,
6090                        lease_id: None,
6091                    } if owner_incarnation_id != &self.incarnation_id => {
6092                        previous = Some(record.clone());
6093                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6094                            owner_incarnation_id: self.incarnation_id,
6095                            lease_id: None,
6096                        };
6097                        record.updated_at_unix_ms = unix_time_ms();
6098                    }
6099                    ManagedWorktreeSpawnReplayStateV10::Pending {
6100                        lease_id: None,
6101                        ..
6102                    } => {
6103                        return Err(failure(
6104                            NodeFailureCode::BackendBusy,
6105                            "managed worktree V2 reservation is already active",
6106                        ));
6107                    }
6108                    ManagedWorktreeSpawnReplayStateV10::Pending {
6109                        lease_id: Some(_),
6110                        ..
6111                    } => {
6112                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6113                    }
6114                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6115                        return Ok(DurableManagedSpawnReplayDecision::Committed(
6116                            receipt.clone(),
6117                        ));
6118                    }
6119                }
6120            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6121                return Err(failure(
6122                    NodeFailureCode::SpawnIdempotencyCapacity,
6123                    "durable managed worktree replay ledger reached its bounded capacity",
6124                ));
6125            } else {
6126                let now = unix_time_ms();
6127                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6128                    idempotency_key: key.clone(),
6129                    request_digest,
6130                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6131                    profile_id: request.worktree_profile_id.clone(),
6132                    expected_profile_revision: request.expected_profile_revision.clone(),
6133                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
6134                        owner_incarnation_id: self.incarnation_id,
6135                        lease_id: None,
6136                    },
6137                    created_at_unix_ms: now,
6138                    updated_at_unix_ms: now,
6139                });
6140            }
6141        }
6142        if let Err(error) = self.persist_state_locked() {
6143            let mut replays = self.managed_spawn_replays
6144                .lock()
6145                .unwrap_or_else(|poisoned| poisoned.into_inner());
6146            match previous {
6147                Some(previous) => {
6148                    replays.insert(key, previous);
6149                }
6150                None => {
6151                    replays.remove(&key);
6152                }
6153            }
6154            return Err(persistence_failure(error));
6155        }
6156        Ok(DurableManagedSpawnReplayDecision::Reserved)
6157    }
6158
6159    fn link_durable_managed_spawn_v2_lease_locked(
6160        &self,
6161        key: &SpawnIdempotencyKey,
6162        lease_id: &ManagedWorktreeLeaseId,
6163    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6164        let mut replays = self.managed_spawn_replays
6165            .lock()
6166            .unwrap_or_else(|poisoned| poisoned.into_inner());
6167        let record = replays.get_mut(key).ok_or_else(|| {
6168            failure(
6169                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6170                "managed worktree V2 reservation disappeared before allocation",
6171            )
6172        })?;
6173        let previous = record.clone();
6174        match &mut record.state {
6175            ManagedWorktreeSpawnReplayStateV10::Pending {
6176                lease_id: current,
6177                ..
6178            }
6179                if current.is_none() =>
6180            {
6181                *current = Some(lease_id.clone());
6182                record.updated_at_unix_ms = unix_time_ms();
6183                Ok(previous)
6184            }
6185            _ => Err(failure(
6186                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6187                "managed worktree V2 reservation is not allocation-ready",
6188            )),
6189        }
6190    }
6191
6192    fn restore_durable_managed_spawn_v2_locked(
6193        &self,
6194        key: &SpawnIdempotencyKey,
6195        previous: ManagedWorktreeSpawnReplayRecordV10,
6196    ) {
6197        self.managed_spawn_replays
6198            .lock()
6199            .unwrap_or_else(|poisoned| poisoned.into_inner())
6200            .insert(key.clone(), previous);
6201    }
6202
6203    fn commit_durable_managed_spawn_v2(
6204        &self,
6205        request: &ManagedWorktreeSpawnRequestV2,
6206        receipt: ManagedWorktreeSpawnReceipt,
6207    ) -> Result<(), NodeFailure> {
6208        let _transaction = self.state_transaction
6209            .lock()
6210            .unwrap_or_else(|poisoned| poisoned.into_inner());
6211        let key = &request.spawn_spec.idempotency_key;
6212        let previous = {
6213            let mut replays = self.managed_spawn_replays
6214                .lock()
6215                .unwrap_or_else(|poisoned| poisoned.into_inner());
6216            let record = replays.get_mut(key).ok_or_else(|| {
6217                failure(
6218                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6219                    "managed worktree V2 reservation disappeared before commit",
6220                )
6221            })?;
6222            let previous = record.clone();
6223            match &record.state {
6224                ManagedWorktreeSpawnReplayStateV10::Pending {
6225                    lease_id: Some(lease_id),
6226                    ..
6227                } if lease_id == &receipt.lease.lease_id => {}
6228                _ => {
6229                    return Err(failure(
6230                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
6231                        "managed worktree V2 reservation is not linked to the spawned lease",
6232                    ));
6233                }
6234            }
6235            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6236            record.updated_at_unix_ms = unix_time_ms();
6237            previous
6238        };
6239        if let Err(error) = self.persist_state_locked() {
6240            self.restore_durable_managed_spawn_v2_locked(key, previous);
6241            return Err(persistence_failure(error));
6242        }
6243        Ok(())
6244    }
6245
6246    fn remove_unallocated_durable_managed_spawn_v2(
6247        &self,
6248        request: &ManagedWorktreeSpawnRequestV2,
6249    ) -> Result<(), NodeFailure> {
6250        let _transaction = self.state_transaction
6251            .lock()
6252            .unwrap_or_else(|poisoned| poisoned.into_inner());
6253        let key = &request.spawn_spec.idempotency_key;
6254        let removed = {
6255            let mut replays = self.managed_spawn_replays
6256                .lock()
6257                .unwrap_or_else(|poisoned| poisoned.into_inner());
6258            match replays.get(key).map(|record| &record.state) {
6259                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6260                    lease_id: None,
6261                    ..
6262                }) => {
6263                    replays.remove(key)
6264                }
6265                _ => None,
6266            }
6267        };
6268        let Some(removed) = removed else {
6269            return Ok(());
6270        };
6271        if let Err(error) = self.persist_state_locked() {
6272            self.managed_spawn_replays
6273                .lock()
6274                .unwrap_or_else(|poisoned| poisoned.into_inner())
6275                .insert(key.clone(), removed);
6276            return Err(persistence_failure(error));
6277        }
6278        Ok(())
6279    }
6280
6281    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6282        if spec.target.node_id != self.node_id {
6283            return Err(failure(
6284                NodeFailureCode::SpawnTargetMismatch,
6285                "spawn target does not match this node",
6286            ));
6287        }
6288        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6289            failure(
6290                NodeFailureCode::UnknownSpawnProfile,
6291                "spawn profile is unavailable on this node",
6292            )
6293        })?;
6294        let resolved = spec.resolve(defaults).map_err(|error| match error {
6295            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6296                NodeFailureCode::SpawnProfileRevisionMismatch,
6297                "spawn profile revision does not match the loaded profile",
6298            ),
6299            _ => failure(
6300                NodeFailureCode::InvalidRequest,
6301                "spawn specification could not be resolved",
6302            ),
6303        })?;
6304        if resolved.target.worktree_id.is_some() {
6305            self.require_worktree_service(&resolved.target.workspace_id)?;
6306        }
6307        let environment_profile = self.resolve_environment_profile(&resolved)?;
6308        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6309        self.resolve_context(&resolved)?;
6310        Ok(resolved)
6311    }
6312
6313    async fn resolve_spawn_workspace(
6314        &self,
6315        resolved: &ResolvedSpawnSpec,
6316        deadline: Instant,
6317    ) -> Result<WorkspaceId, NodeFailure> {
6318        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6319            return Ok(resolved.target.workspace_id.clone());
6320        };
6321        if self.managed_worktrees.lock()
6322            .unwrap_or_else(|poisoned| poisoned.into_inner())
6323            .lease_for_workspace(worktree_id)
6324            .is_some()
6325        {
6326            return Err(failure(
6327                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6328                "managed worktree targets may only be spawned through their lease request",
6329            ));
6330        }
6331        self.require_worktree_service(&resolved.target.workspace_id)?;
6332        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6333        let selected_root = self.workspace_root(worktree_id)?;
6334        let remaining = spawn_deadline_remaining(deadline)?;
6335        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6336            .await
6337            .map_err(|_| {
6338                failure(
6339                    NodeFailureCode::SpawnDeadlineExceeded,
6340                    "Git worktree selection exceeded the spawn deadline",
6341                )
6342            })?
6343            .map_err(git_worktree_failure)?;
6344        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6345        Ok(worktree_id.clone())
6346    }
6347
6348    async fn spawn_from_spec(
6349        &self,
6350        spec: SpawnSpec,
6351    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6352        self.spawn_from_spec_with_proxy(spec, None).await
6353    }
6354
6355    async fn spawn_from_spec_with_proxy(
6356        &self,
6357        spec: SpawnSpec,
6358        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6359    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6360        let accepted_at = Instant::now();
6361        if harness_mcp.is_none() {
6362            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6363                return replayed;
6364            }
6365        }
6366        let resolved = self.resolve_spawn_spec(&spec)?;
6367        let environment_profile = self.resolve_environment_profile(&resolved)?;
6368        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6369        let context = self.resolve_context(&resolved)?;
6370        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6371        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6372        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6373        let spawn_workspace_id = match spawn_workspace_id {
6374            Ok(workspace_id) => workspace_id,
6375            Err(error) => {
6376                let result = Err(error);
6377                if harness_mcp.is_none() {
6378                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6379                }
6380                return result;
6381            }
6382        };
6383        if self.managed_worktrees.lock()
6384            .unwrap_or_else(|poisoned| poisoned.into_inner())
6385            .lease_for_workspace(&spawn_workspace_id)
6386            .is_some()
6387        {
6388            let result = Err(failure(
6389                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6390                "managed worktree targets may only be spawned through their lease request",
6391            ));
6392            if harness_mcp.is_none() {
6393                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6394            }
6395            return result;
6396        }
6397        let result = self
6398            .spawn_session_with_deadline(
6399                spawn_workspace_id,
6400                resolved.provider.clone(),
6401                resolved.mode,
6402                resolved.terminal_size,
6403                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6404                environment_profile.clone(),
6405                bundle.clone(),
6406                context.clone(),
6407                None,
6408                None,
6409                SpawnRecordPolicy::Always,
6410                Some(deadline),
6411                &required_capabilities,
6412                harness_mcp,
6413                resolved.approval_level,
6414                resolved.network_allowlist.clone(),
6415                resolved.browser_profile_id.clone(),
6416            )
6417            .await
6418            .map(|(session, _runtime_policy)| {
6419                resolved.receipt_with_materialization(
6420                    self.incarnation_id,
6421                    session,
6422                    environment_profile,
6423                    bundle,
6424                    context,
6425                )
6426            });
6427        if harness_mcp.is_none() {
6428            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6429        }
6430        result
6431    }
6432
6433    async fn spawn_from_spec_with_harness_mcp(
6434        &self,
6435        reservation_id: HarnessMcpReservationId,
6436        activation_digest: HarnessMcpActivationDigest,
6437        spec: SpawnSpec,
6438        deadline_unix_ms: u64,
6439    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6440        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6441            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6442        })?;
6443        if let Some(receipt) = registry
6444            .replay_spawn(&reservation_id, &activation_digest, &spec)
6445            .map_err(harness_mcp_failure)?
6446        {
6447            return Ok(receipt);
6448        }
6449        let resolved = self.resolve_spawn_spec(&spec)?;
6450        let prepared = registry
6451            .prepare_spawn(
6452                &reservation_id,
6453                &activation_digest,
6454                &spec,
6455                deadline_unix_ms,
6456                resolved.provider.clone(),
6457            )
6458            .map_err(harness_mcp_failure)?;
6459        self.claim_harness_mcp_spawn(
6460            &reservation_id,
6461            &activation_digest,
6462            &spec,
6463            Instant::now(),
6464        )?;
6465        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6466        let mut receipt = match spawn {
6467            Ok(receipt) => receipt,
6468            Err(error) => {
6469                let _ = registry.abort(&reservation_id, &activation_digest);
6470                return Err(error);
6471            }
6472        };
6473        let record_id = self
6474            .session_bindings
6475            .lock()
6476            .unwrap_or_else(|poisoned| poisoned.into_inner())
6477            .get(&receipt.session.session.instance_id)
6478            .and_then(|binding| binding.record_id.clone());
6479        let record_id = match record_id {
6480            Some(record_id) => record_id,
6481            None => {
6482                let rollback = self.remove_session(&receipt.session).await;
6483                let _ = registry.abort(&reservation_id, &activation_digest);
6484                if let Err(rollback_error) = rollback {
6485                    return Err(rollback_error);
6486                }
6487                return Err(failure(
6488                    NodeFailureCode::BindingMismatch,
6489                    "harness MCP spawn did not retain its exact managed record",
6490                ));
6491            }
6492        };
6493        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6494            reservation_id: reservation_id.clone(),
6495            activation_digest: activation_digest.clone(),
6496        });
6497        if let Err(error) = registry.mark_spawned(
6498            &prepared,
6499            receipt.session.clone(),
6500            record_id,
6501            receipt.clone(),
6502        ) {
6503            let rollback = self.remove_session(&receipt.session).await;
6504            let _ = registry.abort(&reservation_id, &activation_digest);
6505            if let Err(rollback_error) = rollback {
6506                return Err(rollback_error);
6507            }
6508            return Err(harness_mcp_failure(error));
6509        }
6510        Ok(receipt)
6511    }
6512
6513    async fn spawn_managed_worktree(
6514        &self,
6515        request: ManagedWorktreeSpawnRequest,
6516    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6517        self.spawn_managed_worktree_inner(request, None).await
6518    }
6519
6520    async fn spawn_managed_worktree_inner(
6521        &self,
6522        request: ManagedWorktreeSpawnRequest,
6523        durable_v2_key: Option<&SpawnIdempotencyKey>,
6524    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6525        let accepted_at = Instant::now();
6526        if durable_v2_key.is_none() {
6527            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6528                return replayed;
6529            }
6530        }
6531        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6532        if request.spawn_spec.target.worktree_id.is_some() {
6533            let result = Err(failure(
6534                NodeFailureCode::InvalidRequest,
6535                "managed worktree spawn must not select a caller-provided worktree",
6536            ));
6537            self.remember_managed_spawn_attempt(
6538                durable_v2_key,
6539                request,
6540                result.clone(),
6541                accepted_at,
6542            );
6543            return result;
6544        }
6545        let environment_profile = self.resolve_environment_profile(&resolved)?;
6546        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6547        let context = self.resolve_context(&resolved)?;
6548        let source_workspace_id = resolved.target.workspace_id.clone();
6549        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6550        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6551        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6552        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6553            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6554            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6555            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6556            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6557        };
6558        let runtime_policy = timeout(
6559            spawn_deadline_remaining(deadline)?,
6560            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6561        ).await.map_err(|_| failure(
6562            NodeFailureCode::SpawnDeadlineExceeded,
6563            "provider admission exceeded the managed spawn deadline",
6564        ))??;
6565        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6566            return Err(failure(
6567                NodeFailureCode::UnsupportedSpawnCapability,
6568                "provider runtime does not admit a required spawn capability",
6569            ));
6570        }
6571        self.ensure_binding_capacity()?;
6572        let source_root = self.workspace_root(&source_workspace_id)?;
6573        profile.validate_for_workspace(&source_root)
6574            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6575        let base_timeout_ms = u64::try_from(
6576            spawn_deadline_remaining(deadline)?.as_millis(),
6577        ).unwrap_or(u64::MAX).max(1);
6578        let base_commit = resolve_base_commit_with_timeout(
6579            &source_root,
6580            profile.base(),
6581            base_timeout_ms,
6582        ).await.map_err(|error| {
6583            if Instant::now() >= deadline || error.message.contains("timed out") {
6584                failure(
6585                    NodeFailureCode::SpawnDeadlineExceeded,
6586                    "managed worktree base resolution exceeded the spawn deadline",
6587                )
6588            } else {
6589                managed_git_worktree_failure(error)
6590            }
6591        })?;
6592
6593        let lease = {
6594            let _transaction = self.state_transaction.lock()
6595                .unwrap_or_else(|poisoned| poisoned.into_inner());
6596            let previous = self.managed_worktrees.lock()
6597                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6598            let lease = self.managed_worktrees.lock()
6599                .unwrap_or_else(|poisoned| poisoned.into_inner())
6600                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6601                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6602            let previous_replay = match durable_v2_key
6603                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6604                .transpose()
6605            {
6606                Ok(previous) => previous,
6607                Err(error) => {
6608                    *self.managed_worktrees.lock()
6609                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6610                    return Err(error);
6611                }
6612            };
6613            if let Err(error) = self.persist_state_locked() {
6614                *self.managed_worktrees.lock()
6615                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6616                if let (Some(key), Some(previous_replay)) =
6617                    (durable_v2_key, previous_replay)
6618                {
6619                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6620                }
6621                return Err(persistence_failure(error));
6622            }
6623            lease
6624        };
6625        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6626        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6627            self.managed_worktrees.lock()
6628                .unwrap_or_else(|poisoned| poisoned.into_inner())
6629                .remove_unmutated(&lease.lease_id);
6630            if self.persist_state().is_err() {
6631                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6632            }
6633            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6634            self.remember_managed_spawn_attempt(
6635                durable_v2_key,
6636                request,
6637                result.clone(),
6638                accepted_at,
6639            );
6640            return result;
6641        }
6642        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6643            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6644            Err(error) => {
6645                self.reconcile_failed_allocation(&lease, &source_root).await;
6646                let result = Err(error);
6647                self.remember_managed_spawn_attempt(
6648                    durable_v2_key,
6649                    request,
6650                    result.clone(),
6651                    accepted_at,
6652                );
6653                return result;
6654            }
6655        };
6656        let created = match create_git_worktree_with_timeout(
6657                &source_root,
6658                &lease.target_root,
6659                &lease.branch,
6660                Some(&base_commit),
6661                mutation_timeout_ms,
6662            ).await {
6663            Ok(created) => created,
6664            Err(error) => {
6665                self.reconcile_failed_allocation(&lease, &source_root).await;
6666                let result = Err(if Instant::now() >= deadline
6667                    || error.message.contains("timed out")
6668                    || error.message.contains("deadline")
6669                {
6670                    failure(
6671                        NodeFailureCode::SpawnDeadlineExceeded,
6672                        "managed worktree creation exceeded the spawn deadline",
6673                    )
6674                } else {
6675                    managed_git_worktree_failure(error)
6676                });
6677                self.remember_managed_spawn_attempt(
6678                    durable_v2_key,
6679                    request,
6680                    result.clone(),
6681                    accepted_at,
6682                );
6683                return result;
6684            }
6685        };
6686        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6687            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6688            let result = Err(failure(
6689                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6690                "created worktree identity did not match its durable lease",
6691            ));
6692            self.remember_managed_spawn_attempt(
6693                durable_v2_key,
6694                request,
6695                result.clone(),
6696                accepted_at,
6697            );
6698            return result;
6699        }
6700        {
6701            let mut registry = self.managed_worktrees.lock()
6702                .unwrap_or_else(|poisoned| poisoned.into_inner());
6703            let current = registry.get_mut(&lease.lease_id)
6704                .expect("allocated managed worktree lease remains present");
6705            current.expected_head = Some(created.head.clone());
6706            current.state = ManagedWorktreeLeaseState::Ready;
6707            current.updated_at_unix_ms = unix_time_ms();
6708        }
6709        if let Err(error) = self.persist_state() {
6710            self.mark_managed_recovery_required(
6711                &lease.lease_id,
6712                ManagedWorktreeCleanupFailure::Backend,
6713            );
6714            let result = Err(persistence_failure(error));
6715            self.remember_managed_spawn_attempt(
6716                durable_v2_key,
6717                request,
6718                result.clone(),
6719                accepted_at,
6720            );
6721            return result;
6722        }
6723        let ready = self.managed_worktrees.lock()
6724            .unwrap_or_else(|poisoned| poisoned.into_inner())
6725            .get(&lease.lease_id)
6726            .expect("ready managed worktree lease remains present")
6727            .snapshot();
6728        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6729        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6730            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6731            let result = Err(error);
6732            self.remember_managed_spawn_attempt(
6733                durable_v2_key,
6734                request,
6735                result.clone(),
6736                accepted_at,
6737            );
6738            return result;
6739        }
6740        let spawn = self.spawn_session_with_deadline(
6741            lease.workspace_id.clone(),
6742            resolved.provider.clone(),
6743            resolved.mode,
6744            resolved.terminal_size,
6745            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6746            environment_profile.clone(),
6747            bundle.clone(),
6748            context.clone(),
6749            Some(lease.lease_id.clone()),
6750            Some(runtime_policy),
6751            SpawnRecordPolicy::Always,
6752            Some(deadline),
6753            &required_capabilities,
6754            None,
6755            resolved.approval_level,
6756            resolved.network_allowlist.clone(),
6757            resolved.browser_profile_id.clone(),
6758        ).await;
6759        let (session, effective_runtime_policy) = match spawn {
6760            Ok(spawned) => spawned,
6761            Err(error) => {
6762                if self
6763                    .cleanup_managed_worktree(&lease.lease_id, true)
6764                    .await
6765                    .is_err()
6766                {
6767                    self.mark_managed_recovery_required(
6768                        &lease.lease_id,
6769                        ManagedWorktreeCleanupFailure::Backend,
6770                    );
6771                }
6772                let result = Err(error);
6773                self.remember_managed_spawn_attempt(
6774                    durable_v2_key,
6775                    request,
6776                    result.clone(),
6777                    accepted_at,
6778                );
6779                return result;
6780            }
6781        };
6782        let (record_id, raw_inventory_record_id) = {
6783            let mut bindings = self.session_bindings.lock()
6784                .unwrap_or_else(|poisoned| poisoned.into_inner());
6785            let binding = bindings.get_mut(&session.session.instance_id)
6786                .expect("successful spawn retains its session binding");
6787            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6788            managed_spawn_record_ownership(binding, effective_runtime_policy)
6789        };
6790        let bound = self.managed_worktrees.lock()
6791            .unwrap_or_else(|poisoned| poisoned.into_inner())
6792            .bind_session(
6793                &lease.lease_id,
6794                ManagedWorktreeSessionHolder {
6795                    incarnation_id: self.incarnation_id,
6796                    instance_id: session.session.instance_id,
6797                    generation: session.session.generation,
6798                },
6799                record_id,
6800                unix_time_ms(),
6801            )
6802            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6803        let snapshot = match bound {
6804            Ok(snapshot) => snapshot,
6805            Err(error) => {
6806                if raw_inventory_record_id
6807                    .as_ref()
6808                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
6809                {
6810                    self.mark_managed_recovery_required(
6811                        &lease.lease_id,
6812                        ManagedWorktreeCleanupFailure::Backend,
6813                    );
6814                }
6815                let _ = self.remove_session(&session).await;
6816                self.mark_managed_recovery_required(
6817                    &lease.lease_id,
6818                    ManagedWorktreeCleanupFailure::Backend,
6819                );
6820                let result = Err(error);
6821                self.remember_managed_spawn_attempt(
6822                    durable_v2_key,
6823                    request,
6824                    result.clone(),
6825                    accepted_at,
6826                );
6827                return result;
6828            }
6829        };
6830        if let Err(error) = self.persist_state() {
6831            if raw_inventory_record_id
6832                .as_ref()
6833                .is_some_and(|record_id| self.discard_record(record_id).is_err())
6834            {
6835                self.mark_managed_recovery_required(
6836                    &lease.lease_id,
6837                    ManagedWorktreeCleanupFailure::Backend,
6838                );
6839            }
6840            let _ = self.remove_session(&session).await;
6841            if self
6842                .cleanup_managed_worktree(&lease.lease_id, true)
6843                .await
6844                .is_err()
6845            {
6846                self.mark_managed_recovery_required(
6847                    &lease.lease_id,
6848                    ManagedWorktreeCleanupFailure::Backend,
6849                );
6850            }
6851            let result = Err(persistence_failure(error));
6852            self.remember_managed_spawn_attempt(
6853                durable_v2_key,
6854                request,
6855                result.clone(),
6856                accepted_at,
6857            );
6858            return result;
6859        }
6860        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6861        let receipt = managed_spawn_receipt(
6862            &resolved,
6863            self.incarnation_id,
6864            session,
6865            snapshot,
6866            environment_profile,
6867            bundle,
6868            context,
6869        );
6870        let result = Ok(receipt);
6871        self.remember_managed_spawn_attempt(
6872            durable_v2_key,
6873            request,
6874            result.clone(),
6875            accepted_at,
6876        );
6877        result
6878    }
6879
6880    async fn spawn_managed_worktree_v2(
6881        &self,
6882        request: ManagedWorktreeSpawnRequestV2,
6883    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6884        if request.spawn_spec.target.node_id != self.node_id {
6885            return Err(failure(
6886                NodeFailureCode::SpawnTargetMismatch,
6887                "spawn target does not match this node",
6888            ));
6889        }
6890        if request.spawn_spec.target.worktree_id.is_some() {
6891            return Err(failure(
6892                NodeFailureCode::InvalidRequest,
6893                "managed worktree spawn must not select a caller-provided worktree",
6894            ));
6895        }
6896        let request_digest = managed_spawn_request_digest_v2(&request)?;
6897        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6898        match replay {
6899            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6900            DurableManagedSpawnReplayDecision::PendingLinked => {
6901                return Err(failure(
6902                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6903                    "managed worktree V2 reservation has an incomplete durable allocation",
6904                ));
6905            }
6906            DurableManagedSpawnReplayDecision::Reserved => {}
6907        }
6908        let validated = (|| {
6909            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6910            let profile = self.managed_profile(
6911                &resolved.target.workspace_id,
6912                &request.worktree_profile_id,
6913            )?;
6914            if profile.revision() != &request.expected_profile_revision {
6915                return Err(failure(
6916                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6917                    "managed worktree profile revision does not match the loaded profile",
6918                ));
6919            }
6920            Ok(())
6921        })();
6922        match validated {
6923            Ok(()) => {}
6924            Err(error) => {
6925                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6926                return Err(error);
6927            }
6928        }
6929        let legacy = ManagedWorktreeSpawnRequest {
6930            spawn_spec: request.spawn_spec.clone(),
6931            worktree_profile_id: request.worktree_profile_id.clone(),
6932        };
6933        match self
6934            .spawn_managed_worktree_inner(
6935                legacy,
6936                Some(&request.spawn_spec.idempotency_key),
6937            )
6938            .await
6939        {
6940            Ok(receipt) => {
6941                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6942                Ok(receipt)
6943            }
6944            Err(error) => {
6945                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6946                Err(error)
6947            }
6948        }
6949    }
6950
6951    fn managed_profile(
6952        &self,
6953        workspace_id: &WorkspaceId,
6954        profile_id: &WorktreeProfileId,
6955    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6956        if self.worktree_service_modes.get(workspace_id).copied()
6957            != Some(WorktreeServiceMode::Managed)
6958        {
6959            return Err(failure(
6960                NodeFailureCode::UnsupportedSpawnCapability,
6961                "workspace does not enable managed worktrees",
6962            ));
6963        }
6964        self.managed_worktree_profiles.get(workspace_id)
6965            .and_then(|profiles| profiles.get(profile_id))
6966            .cloned()
6967            .ok_or_else(|| failure(
6968                NodeFailureCode::InvalidRequest,
6969                "managed worktree profile is unavailable for this workspace",
6970            ))
6971    }
6972
6973    async fn reconcile_failed_allocation(
6974        &self,
6975        lease: &ManagedWorktreeLeaseRecord,
6976        source_root: &str,
6977    ) {
6978        match list_git_worktrees(source_root).await {
6979            Ok(worktrees) => {
6980                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6981                    None => {
6982                        match std::fs::symlink_metadata(&lease.target_root) {
6983                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
6984                                self.managed_worktrees.lock()
6985                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6986                                    .remove_unmutated(&lease.lease_id);
6987                                let _ = self.persist_state();
6988                            }
6989                            _ => self.mark_managed_recovery_required(
6990                                &lease.lease_id,
6991                                ManagedWorktreeCleanupFailure::OwnershipConflict,
6992                            ),
6993                        }
6994                    }
6995                    Some(item) if exact_owned_worktree(
6996                        lease,
6997                        &item.path,
6998                        item.branch.as_deref(),
6999                        &item.head,
7000                    ) => {
7001                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7002                    }
7003                    Some(_) => self.mark_managed_recovery_required(
7004                        &lease.lease_id,
7005                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7006                    ),
7007                }
7008            }
7009            Err(_) => self.mark_managed_recovery_required(
7010                &lease.lease_id,
7011                ManagedWorktreeCleanupFailure::Backend,
7012            ),
7013        }
7014    }
7015
7016    async fn reconcile_managed_worktrees(&self) {
7017        let leases = self.managed_worktrees.lock()
7018            .unwrap_or_else(|poisoned| poisoned.into_inner())
7019            .active_records()
7020            .cloned()
7021            .collect::<Vec<_>>();
7022        for lease in leases {
7023            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7024                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7025                _ => {
7026                    self.mark_managed_recovery_required(
7027                        &lease.lease_id,
7028                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7029                    );
7030                    continue;
7031                }
7032            };
7033            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7034                self.mark_managed_recovery_required(
7035                    &lease.lease_id,
7036                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7037                );
7038                continue;
7039            }
7040            let source_root = match self.workspace_root(&lease.source_workspace_id) {
7041                Ok(root) => root,
7042                Err(_) => {
7043                    self.mark_managed_recovery_required(
7044                        &lease.lease_id,
7045                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7046                    );
7047                    continue;
7048                }
7049            };
7050            let worktrees = match list_git_worktrees(&source_root).await {
7051                Ok(worktrees) => worktrees,
7052                Err(_) => {
7053                    self.mark_managed_recovery_required(
7054                        &lease.lease_id,
7055                        ManagedWorktreeCleanupFailure::Backend,
7056                    );
7057                    continue;
7058                }
7059            };
7060            let listed = worktrees.iter()
7061                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7062            let Some(listed) = listed else {
7063                match std::fs::symlink_metadata(&lease.target_root) {
7064                    Err(error) if error.kind() == io::ErrorKind::NotFound
7065                        && lease.state == ManagedWorktreeLeaseState::Allocating
7066                        && !lease.has_holders() => {
7067                            let removed = self.managed_worktrees.lock()
7068                                .unwrap_or_else(|poisoned| poisoned.into_inner())
7069                                .tombstone(&lease.lease_id, unix_time_ms());
7070                            if self.persist_state().is_ok() && removed.is_some() {
7071                                self.publish(NodeEvent::ManagedWorktreeRemoved {
7072                                    lease_id: lease.lease_id.clone(),
7073                                });
7074                            }
7075                        }
7076                    _ => self.mark_managed_recovery_required(
7077                        &lease.lease_id,
7078                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7079                    ),
7080                }
7081                continue;
7082            };
7083            if listed.is_main || listed.is_bare || !exact_owned_worktree(
7084                &lease,
7085                &listed.path,
7086                listed.branch.as_deref(),
7087                &listed.head,
7088            ) {
7089                self.mark_managed_recovery_required(
7090                    &lease.lease_id,
7091                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7092                );
7093                continue;
7094            }
7095            if !self.workspaces.read()
7096                .unwrap_or_else(|poisoned| poisoned.into_inner())
7097                .contains_key(&lease.workspace_id)
7098            {
7099                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7100                    self.mark_managed_recovery_required(
7101                        &lease.lease_id,
7102                        ManagedWorktreeCleanupFailure::Backend,
7103                    );
7104                    continue;
7105                }
7106            }
7107            let snapshot = {
7108                let mut registry = self.managed_worktrees.lock()
7109                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7110                let current = registry.get_mut(&lease.lease_id)
7111                    .expect("reconciled managed lease remains present");
7112                current.expected_head = Some(listed.head.clone());
7113                current.cleanup_failure = None;
7114                current.state = if current.has_holders() {
7115                    ManagedWorktreeLeaseState::InUse
7116                } else if current.retention == ManagedWorktreeRetention::Retain {
7117                    ManagedWorktreeLeaseState::Retained
7118                } else {
7119                    ManagedWorktreeLeaseState::Ready
7120                };
7121                current.updated_at_unix_ms = unix_time_ms();
7122                current.snapshot()
7123            };
7124            if self.persist_state().is_err() {
7125                self.mark_managed_recovery_required(
7126                    &lease.lease_id,
7127                    ManagedWorktreeCleanupFailure::Backend,
7128                );
7129                continue;
7130            }
7131            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7132            if snapshot.state == ManagedWorktreeLeaseState::Ready {
7133                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7134            }
7135        }
7136    }
7137
7138    /// Crash-recovery startup sweep: durably capture the pack for every
7139    /// `Live` managed record whose most recent session has not yet produced
7140    /// one (recovers a session whose `Exited{0}` event was observed but the
7141    /// Node crashed before the export finished). The reactive fast path for
7142    /// a single just-exited record does not go through this — see
7143    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
7144    /// it can capture the exact `SessionAddress` before `publish_control`
7145    /// clears it, rather than re-deriving it from (by then likely already
7146    /// `Dormant`) record state the way a rescan naturally would.
7147    ///
7148    /// Returns as soon as every eligible record's export has been *spawned*,
7149    /// not completed — each one runs on its own detached task (see
7150    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
7151    /// `NativeRuntime::tick()` has not even started its own loop yet at the
7152    /// point this is called from `NodeServer::run()`, so nothing could ever
7153    /// drain the history round trip if this awaited it inline here either).
7154    /// Best-effort throughout: never panics, no unwrap/expect; every failure
7155    /// is a silent skip for THIS record — an unusable source or a racing
7156    /// record is not surfaced to any caller.
7157    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7158        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7159            .session_records
7160            .lock()
7161            .unwrap_or_else(|poisoned| poisoned.into_inner())
7162            .records
7163            .values()
7164            .filter(|record| {
7165                record.state == ManagedSessionState::Live
7166                    && record.exported_context.is_none()
7167                    && record.provider_session.is_some()
7168            })
7169            .filter_map(|record| {
7170                record
7171                    .active_session
7172                    .clone()
7173                    .map(|session| (record.record_id.clone(), session))
7174            })
7175            .collect();
7176        for (record_id, session) in candidates {
7177            let shared = Arc::clone(self);
7178            tokio::spawn(async move {
7179                shared
7180                    .reconcile_context_pack_export_for_record(&record_id, session)
7181                    .await;
7182            });
7183        }
7184    }
7185
7186    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
7187    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
7188    /// round trip this performs (`discover_history`/`load_history`, via
7189    /// `export_context_pack_for_session_record`) can only ever settle once
7190    /// `NativeRuntime::tick()` runs again to drain the authority worker's
7191    /// completion — and `tick()` is exclusively owned by, and only ever
7192    /// called from, that same drive-loop task. Awaiting this inline there
7193    /// would starve `tick()` and this call could never observe its own
7194    /// command complete.
7195    ///
7196    /// Because this now genuinely races the drive loop's own
7197    /// `publish_control` (which downgrades this exact record from `Live` to
7198    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
7199    /// triggered this reconcile), the record legitimately may already be
7200    /// `Dormant` by the time this reads it, or may transition mid-flight —
7201    /// `allow_clean_detachment: true` on the downstream export call is what
7202    /// tolerates exactly that transition (and only that transition; see
7203    /// `session_record_export_target_matches`).
7204    async fn reconcile_context_pack_export_for_record(
7205        &self,
7206        record_id: &SessionRecordId,
7207        session: SessionAddress,
7208    ) {
7209        let Ok(record) = self.record(record_id) else {
7210            return;
7211        };
7212        if !matches!(
7213            record.state,
7214            ManagedSessionState::Live | ManagedSessionState::Dormant
7215        ) || record.exported_context.is_some()
7216            || record.provider_session.is_none()
7217        {
7218            return;
7219        }
7220        // Pack bytes are already durably committed inside this call, via
7221        // commit_context_pack_for_session_record's own durable
7222        // write-through (context_pack_store) — no separate step here.
7223        // Unclean exits (non-zero/forced/no loadable history) are already
7224        // rejected by context_pack_source_status_is_usable inside
7225        // materialize_context_pack: this simply returns Err and is skipped.
7226        let Ok(receipt) = self
7227            .export_context_pack_for_session_record(&record.record_id, &session, true)
7228            .await
7229        else {
7230            return;
7231        };
7232        self.set_exported_context(&record.record_id, receipt);
7233    }
7234
7235    /// Records the record's most recent clean-exit pack. No-op (not an
7236    /// error) if the record disappeared, or if it already carries an
7237    /// `exported_context` — first-write-wins, matching the harness-side
7238    /// idempotent reconciliation that consumes this field.
7239    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7240        let transaction = self
7241            .state_transaction
7242            .lock()
7243            .unwrap_or_else(|poisoned| poisoned.into_inner());
7244        let (previous, updated) = {
7245            let mut records = self
7246                .session_records
7247                .lock()
7248                .unwrap_or_else(|poisoned| poisoned.into_inner());
7249            let Some(record) = records.records.get_mut(record_id) else {
7250                return;
7251            };
7252            if record.exported_context.is_some() {
7253                return;
7254            }
7255            let previous = record.clone();
7256            record.exported_context = Some(receipt);
7257            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7258            (previous, record.clone())
7259        };
7260        if self.persist_state_locked().is_err() {
7261            self.session_records
7262                .lock()
7263                .unwrap_or_else(|poisoned| poisoned.into_inner())
7264                .records
7265                .insert(record_id.clone(), previous);
7266            return;
7267        }
7268        drop(transaction);
7269        self.publish_record(updated);
7270    }
7271
7272    /// Resolves the managed record and its exact `SessionAddress` currently
7273    /// bound to `instance_id` at exactly `generation`, used by the reactive
7274    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
7275    /// record without a full rescan. Mirrors the lookup already inlined in
7276    /// `publish_control`. Captured synchronously, before `publish_control`
7277    /// for the same event clears the record's own `active_session` — the
7278    /// caller passes the resolved address through to a detached
7279    /// reconciliation task rather than re-deriving it from the record later,
7280    /// after that race has necessarily already happened.
7281    fn managed_record_export_target_for_instance(
7282        &self,
7283        instance_id: AgentInstanceId,
7284        generation: SessionGeneration,
7285    ) -> Option<(SessionRecordId, SessionAddress)> {
7286        let bindings = self
7287            .session_bindings
7288            .lock()
7289            .unwrap_or_else(|poisoned| poisoned.into_inner());
7290        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7291        let record_id = binding.record_id.clone()?;
7292        Some((
7293            record_id,
7294            SessionAddress {
7295                workspace_id: binding.workspace_id.clone(),
7296                session: SessionKey { instance_id, generation },
7297            },
7298        ))
7299    }
7300
7301    fn mark_managed_recovery_required(
7302        &self,
7303        lease_id: &ManagedWorktreeLeaseId,
7304        failure_kind: ManagedWorktreeCleanupFailure,
7305    ) {
7306        let snapshot = {
7307            let mut registry = self.managed_worktrees.lock()
7308                .unwrap_or_else(|poisoned| poisoned.into_inner());
7309            let Some(lease) = registry.get_mut(lease_id) else { return };
7310            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7311            lease.cleanup_failure = Some(failure_kind);
7312            lease.updated_at_unix_ms = unix_time_ms();
7313            lease.snapshot()
7314        };
7315        let _ = self.persist_state();
7316        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7317    }
7318
7319    fn set_persistence_error(&self, error: Option<String>) {
7320        *self
7321            .persistence_error
7322            .write()
7323            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7324            .map(|message| session_registry::sanitized_persistence_summary(&message));
7325    }
7326
7327    fn persist_state(&self) -> io::Result<()> {
7328        let _transaction = self
7329            .state_transaction
7330            .lock()
7331            .unwrap_or_else(|poisoned| poisoned.into_inner());
7332        self.persist_state_locked()
7333    }
7334
7335    fn persist_state_locked(&self) -> io::Result<()> {
7336        let workspaces = self
7337            .workspaces
7338            .read()
7339            .unwrap_or_else(|poisoned| poisoned.into_inner())
7340            .clone();
7341        let records = self
7342            .session_records
7343            .lock()
7344            .unwrap_or_else(|poisoned| poisoned.into_inner())
7345            .records
7346            .values()
7347            .cloned()
7348            .collect::<Vec<_>>();
7349        let mut managed = self.managed_worktrees
7350            .lock()
7351            .unwrap_or_else(|poisoned| poisoned.into_inner());
7352        managed.reattach_record_holders(
7353            &managed_worktree_record_holders(&records),
7354            unix_time_ms(),
7355        );
7356        let materializations = self
7357            .materializations
7358            .lock()
7359            .unwrap_or_else(|poisoned| poisoned.into_inner())
7360            .values()
7361            .cloned()
7362            .collect::<Vec<_>>();
7363        let managed_spawn_replays = self.managed_spawn_replays
7364            .lock()
7365            .unwrap_or_else(|poisoned| poisoned.into_inner())
7366            .values()
7367            .cloned()
7368            .collect::<Vec<_>>();
7369        let result = session_registry::save_v10(
7370            self.state_path.as_deref(),
7371            &self.node_id,
7372            &workspaces,
7373            &records,
7374            &managed.records(),
7375            &managed.tombstones(),
7376            &materializations,
7377            &managed_spawn_replays,
7378        );
7379        match &result {
7380            Ok(warning) => self.set_persistence_error(warning.clone()),
7381            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7382        }
7383        result.map(|_| ())
7384    }
7385
7386    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7387        let _mutation_guard = self.mutation_gate.lock().await;
7388        self.begin_shutdown_locked().await
7389    }
7390
7391    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7392        if self.shutdown.swap(true, Ordering::AcqRel) {
7393            return Ok(());
7394        }
7395        SessionLifecycleHook::on_shutdown(self);
7396        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7397        let mut first_error = None;
7398        for session in &self.handle.snapshot().sessions {
7399            let remaining = deadline.saturating_duration_since(Instant::now());
7400            if remaining.is_zero() {
7401                first_error.get_or_insert_with(|| {
7402                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7403                });
7404                break;
7405            }
7406            if let Err(error) = self
7407                .dispatch_bounded(
7408                    ControlCommand::Stop {
7409                        instance_id: session.instance_id,
7410                        force: true,
7411                    },
7412                    remaining,
7413                )
7414                .await
7415            {
7416                first_error.get_or_insert(error);
7417            }
7418        }
7419        self.shutdown_notify.notify_waiters();
7420        match first_error {
7421            Some(error) => Err(error),
7422            None => Ok(()),
7423        }
7424    }
7425
7426    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7427        let bindings = self
7428            .session_bindings
7429            .lock()
7430            .unwrap_or_else(|poisoned| poisoned.into_inner());
7431        if bindings.len() >= CONTROL_SESSIONS_MAX {
7432            Err(failure(
7433                NodeFailureCode::BackendBusy,
7434                "node session-binding capacity is full; remove an existing session first",
7435            ))
7436        } else {
7437            Ok(())
7438        }
7439    }
7440
7441    /// One-owner workspace/env collision guard: a live session on
7442    /// `workspace_id` with a different `environment_profile` (option
7443    /// equality on the resolved receipt, including Some vs None) blocks
7444    /// another spawn on that same cwd. Same profile may still share the
7445    /// workspace; distinct profiles need distinct workspace ids (managed
7446    /// worktree).
7447    fn ensure_workspace_environment_profile_compatible(
7448        &self,
7449        workspace_id: &WorkspaceId,
7450        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7451    ) -> Result<(), NodeFailure> {
7452        let bindings = self
7453            .session_bindings
7454            .lock()
7455            .unwrap_or_else(|poisoned| poisoned.into_inner());
7456        for binding in bindings.values() {
7457            if &binding.workspace_id != workspace_id {
7458                continue;
7459            }
7460            if binding.environment_profile.as_ref() != environment_profile {
7461                return Err(failure(
7462                    NodeFailureCode::WorkspaceBusy,
7463                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7464                ));
7465            }
7466        }
7467        Ok(())
7468    }
7469
7470    #[cfg(test)]
7471    fn bind_session(
7472        &self,
7473        instance_id: AgentInstanceId,
7474        workspace_id: WorkspaceId,
7475        generation: SessionGeneration,
7476    ) {
7477        self.bind_session_with_policy(
7478            &SessionAddress {
7479                workspace_id,
7480                session: SessionKey { instance_id, generation },
7481            },
7482            ProviderRuntimePolicy::raw_pty(),
7483            None,
7484        );
7485    }
7486
7487    #[cfg(test)]
7488    fn bind_session_with_policy(
7489        &self,
7490        address: &SessionAddress,
7491        runtime_policy: ProviderRuntimePolicy,
7492        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7493    ) {
7494        self.bind_session_with_materialization(
7495            address,
7496            runtime_policy,
7497            environment_profile,
7498            None,
7499            None,
7500            None,
7501        );
7502    }
7503
7504    fn bind_session_with_materialization(
7505        &self,
7506        address: &SessionAddress,
7507        runtime_policy: ProviderRuntimePolicy,
7508        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7509        bundle: Option<ResolvedBundleReceipt>,
7510        context: Option<ResolvedContextPackReceipt>,
7511        materialization_id: Option<MaterializationId>,
7512    ) {
7513        let mut bindings = self
7514            .session_bindings
7515            .lock()
7516            .unwrap_or_else(|poisoned| poisoned.into_inner());
7517        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7518        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7519        bindings.insert(address.session.instance_id, SessionBinding {
7520            workspace_id: address.workspace_id.clone(),
7521            generation: address.session.generation,
7522            runtime_policy,
7523            pending_resume: None,
7524            record_id: None,
7525            managed_worktree_lease_id: None,
7526            environment_profile,
7527            bundle,
7528            context,
7529            materialization_id,
7530        });
7531    }
7532
7533    #[cfg(test)]
7534    fn bind_managed_session(
7535        &self,
7536        address: &SessionAddress,
7537        record_id: SessionRecordId,
7538        runtime_policy: ProviderRuntimePolicy,
7539        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7540    ) {
7541        self.bind_managed_session_with_materialization(
7542            address,
7543            record_id,
7544            runtime_policy,
7545            environment_profile,
7546            None,
7547            None,
7548            None,
7549        );
7550    }
7551
7552    fn bind_managed_session_with_materialization(
7553        &self,
7554        address: &SessionAddress,
7555        record_id: SessionRecordId,
7556        runtime_policy: ProviderRuntimePolicy,
7557        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7558        bundle: Option<ResolvedBundleReceipt>,
7559        context: Option<ResolvedContextPackReceipt>,
7560        materialization_id: Option<MaterializationId>,
7561    ) {
7562        let mut bindings = self
7563            .session_bindings
7564            .lock()
7565            .unwrap_or_else(|poisoned| poisoned.into_inner());
7566        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7567        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7568        bindings.insert(
7569            address.session.instance_id,
7570            SessionBinding {
7571                workspace_id: address.workspace_id.clone(),
7572                generation: address.session.generation,
7573                runtime_policy,
7574                pending_resume: None,
7575                record_id: Some(record_id),
7576                managed_worktree_lease_id: None,
7577                environment_profile,
7578                bundle,
7579                context,
7580                materialization_id,
7581            },
7582        );
7583    }
7584
7585    #[cfg(test)]
7586    fn bind_spawn_session(
7587        &self,
7588        address: &SessionAddress,
7589        provider: AgentId,
7590        mode: SessionMode,
7591        runtime_policy: ProviderRuntimePolicy,
7592        record_policy: SpawnRecordPolicy,
7593        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7594    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7595        self.bind_spawn_session_with_materialization(
7596            address,
7597            provider,
7598            mode,
7599            runtime_policy,
7600            record_policy,
7601            environment_profile,
7602            None,
7603            None,
7604            None,
7605        )
7606    }
7607
7608    fn bind_spawn_session_with_materialization(
7609        &self,
7610        address: &SessionAddress,
7611        provider: AgentId,
7612        mode: SessionMode,
7613        runtime_policy: ProviderRuntimePolicy,
7614        record_policy: SpawnRecordPolicy,
7615        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7616        bundle: Option<ResolvedBundleReceipt>,
7617        context: Option<ResolvedContextPackReceipt>,
7618        materialization_id: Option<MaterializationId>,
7619    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7620        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7621            && !runtime_policy.provider_session_identity
7622        {
7623            self.bind_session_with_materialization(
7624                address,
7625                runtime_policy,
7626                environment_profile,
7627                bundle,
7628                context,
7629                materialization_id,
7630            );
7631            return Ok(None);
7632        }
7633        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7634        let record = self.new_record(
7635            address,
7636            provider,
7637            mode,
7638            active_record_state(runtime_policy.provider_session_identity, false),
7639            None,
7640            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7641            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7642            record_owns_lifecycle.then(|| context.clone()).flatten(),
7643        )?;
7644        let record_id = record.record_id.clone();
7645        let transaction = self
7646            .state_transaction
7647            .lock()
7648            .unwrap_or_else(|poisoned| poisoned.into_inner());
7649        self.insert_record(record.clone())?;
7650        let previous_materialization = if record_owns_lifecycle {
7651            if let Some(materialization_id) = materialization_id.as_ref() {
7652                let mut materializations = self
7653                    .materializations
7654                    .lock()
7655                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7656                let Some(ownership) = materializations.get_mut(materialization_id) else {
7657                    drop(materializations);
7658                    self.remove_record_memory(&record_id);
7659                    return Err(failure(
7660                        NodeFailureCode::BackendOperationFailed,
7661                        "session environment ownership disappeared before binding",
7662                    ));
7663                };
7664                let previous = ownership.clone();
7665                if ownership
7666                    .transfer_to_record(record_id.clone(), unix_time_ms())
7667                    .is_err()
7668                {
7669                    drop(materializations);
7670                    self.remove_record_memory(&record_id);
7671                    return Err(failure(
7672                        NodeFailureCode::BackendOperationFailed,
7673                        "session environment ownership transfer failed",
7674                    ));
7675                }
7676                Some(previous)
7677            } else {
7678                None
7679            }
7680        } else {
7681            None
7682        };
7683        self.bind_managed_session_with_materialization(
7684            address,
7685            record_id.clone(),
7686            runtime_policy,
7687            environment_profile,
7688            bundle,
7689            context,
7690            materialization_id.clone(),
7691        );
7692        if let Err(error) = self.persist_state_locked() {
7693            self.remove_binding(address);
7694            self.remove_record_memory(&record_id);
7695            if let (Some(materialization_id), Some(previous)) =
7696                (materialization_id.as_ref(), previous_materialization)
7697            {
7698                self.materializations
7699                    .lock()
7700                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7701                    .insert(materialization_id.clone(), previous);
7702            }
7703            return Err(persistence_failure(error));
7704        }
7705        drop(transaction);
7706        self.publish_record(record);
7707        Ok(Some(record_id))
7708    }
7709
7710    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7711        for _ in 0..8 {
7712            let bytes = random_nonce().map_err(|error| {
7713                failure(
7714                    NodeFailureCode::BackendOperationFailed,
7715                    &format!("session record identity generation failed: {error}"),
7716                )
7717            })?;
7718            let mut value = String::with_capacity(27);
7719            value.push_str("sr-");
7720            for byte in &bytes[..12] {
7721                use std::fmt::Write as _;
7722                let _ = write!(&mut value, "{byte:02x}");
7723            }
7724            let record_id = SessionRecordId::new(value).map_err(|error| {
7725                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7726            })?;
7727            let records = self
7728                .session_records
7729                .lock()
7730                .unwrap_or_else(|poisoned| poisoned.into_inner());
7731            if !records.records.contains_key(&record_id) {
7732                return Ok(record_id);
7733            }
7734        }
7735        Err(failure(
7736            NodeFailureCode::BackendBusy,
7737            "could not allocate a unique session record ID",
7738        ))
7739    }
7740
7741    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7742        for _ in 0..8 {
7743            let bytes = random_nonce().map_err(|error| {
7744                failure(
7745                    NodeFailureCode::BackendOperationFailed,
7746                    &format!("task identity generation failed: {error}"),
7747                )
7748            })?;
7749            let mut nonce = [0_u8; 12];
7750            nonce.copy_from_slice(&bytes[..12]);
7751            let task_id = TaskId::from_nonce(nonce);
7752            let records = self
7753                .session_records
7754                .lock()
7755                .unwrap_or_else(|poisoned| poisoned.into_inner());
7756            if !records.records.values().any(|record| {
7757                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7758                    == Some(&task_id)
7759            }) {
7760                return Ok(task_id);
7761            }
7762        }
7763        Err(failure(
7764            NodeFailureCode::BackendBusy,
7765            "could not allocate a unique task ID",
7766        ))
7767    }
7768
7769    fn default_record_name(&self, provider: &AgentId) -> String {
7770        let records = self
7771            .session_records
7772            .lock()
7773            .unwrap_or_else(|poisoned| poisoned.into_inner());
7774        let ordinal = records
7775            .records
7776            .values()
7777            .filter(|record| &record.provider == provider)
7778            .count()
7779            .saturating_add(1);
7780        format!("{} #{ordinal}", provider.as_str())
7781    }
7782
7783    fn new_record(
7784        &self,
7785        address: &SessionAddress,
7786        provider: AgentId,
7787        mode: SessionMode,
7788        state: ManagedSessionState,
7789        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7790        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7791        bundle: Option<ResolvedBundleReceipt>,
7792        context: Option<ResolvedContextPackReceipt>,
7793    ) -> Result<ManagedSessionRecord, NodeFailure> {
7794        let canonical_root = self.workspace_root(&address.workspace_id)?;
7795        let now = unix_time_ms();
7796        Ok(ManagedSessionRecord {
7797            record_id: self.allocate_record_id()?,
7798            display_name: self.default_record_name(&provider),
7799            provider,
7800            mode,
7801            state,
7802            workspace_id: address.workspace_id.clone(),
7803            canonical_root: opaque_windows_path(canonical_root),
7804            provider_session,
7805            active_session: Some(address.clone()),
7806            environment_profile,
7807            bundle,
7808            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7809            context,
7810            exported_context: None,
7811            task_binding: None,
7812            created_at_unix_ms: now,
7813            updated_at_unix_ms: now,
7814            last_error: None,
7815        })
7816    }
7817
7818    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7819        let mut records = self
7820            .session_records
7821            .lock()
7822            .unwrap_or_else(|poisoned| poisoned.into_inner());
7823        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7824            return Err(failure(
7825                NodeFailureCode::BackendBusy,
7826                "managed session record capacity is full; forget an inactive record first",
7827            ));
7828        }
7829        if let Some(identity) = record.provider_session.as_ref() {
7830            if records.records.values().any(|candidate| {
7831                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7832                    session_registry::same_provider_session(
7833                        &candidate.provider,
7834                        candidate_identity,
7835                        &record.provider,
7836                        identity,
7837                    )
7838                })
7839            }) {
7840                return Err(failure(
7841                    NodeFailureCode::SessionRecordConflict,
7842                    "provider session is already represented by another managed record",
7843                ));
7844            }
7845        }
7846        match records.records.entry(record.record_id.clone()) {
7847            std::collections::btree_map::Entry::Vacant(entry) => {
7848                entry.insert(record);
7849            }
7850            std::collections::btree_map::Entry::Occupied(_) => {
7851                return Err(failure(
7852                    NodeFailureCode::SessionRecordConflict,
7853                    "session record ID collision",
7854                ));
7855            }
7856        }
7857        Ok(())
7858    }
7859
7860    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7861        self.session_records
7862            .lock()
7863            .unwrap_or_else(|poisoned| poisoned.into_inner())
7864            .records
7865            .get(record_id)
7866            .cloned()
7867            .ok_or_else(|| {
7868                failure(
7869                    NodeFailureCode::UnknownSessionRecord,
7870                    "managed session record does not exist",
7871                )
7872            })
7873    }
7874
7875    fn revalidate_session_record_preview(
7876        &self,
7877        expected: &ManagedSessionRecord,
7878        identity: &ProviderSessionIdentity,
7879    ) -> Result<(), NodeFailure> {
7880        let current = self.record(&expected.record_id)?;
7881        let current_root = self.workspace_root(&current.workspace_id)?;
7882        if current.provider != expected.provider
7883            || current.workspace_id != expected.workspace_id
7884            || current.canonical_root != expected.canonical_root
7885            || current.provider_session.as_ref() != Some(identity)
7886            || !platform::roots_equal(
7887                &current_root,
7888                windows_path_text(&current.canonical_root),
7889            )
7890        {
7891            return Err(failure(
7892                NodeFailureCode::SessionRecordConflict,
7893                "managed session changed while its preview was loading",
7894            ));
7895        }
7896        Ok(())
7897    }
7898
7899    fn revalidate_session_record_context_export(
7900        &self,
7901        expected: &ManagedSessionRecord,
7902        identity: Option<&ProviderSessionIdentity>,
7903        session: &SessionAddress,
7904        allow_clean_detachment: bool,
7905    ) -> Result<(), NodeFailure> {
7906        let current = self.record(&expected.record_id)?;
7907        let current_root = self.workspace_root(&current.workspace_id)?;
7908        let snapshot = self.internal_session_snapshot(session)?;
7909        let binding = self
7910            .session_bindings
7911            .lock()
7912            .unwrap_or_else(|poisoned| poisoned.into_inner())
7913            .get(&session.session.instance_id)
7914            .cloned()
7915            .ok_or_else(|| {
7916                failure(
7917                    NodeFailureCode::SessionRecordConflict,
7918                    "managed session runtime binding disappeared during context export",
7919                )
7920            })?;
7921        if !session_record_context_export_binding_is_exact(
7922            expected,
7923            &current,
7924            identity,
7925            session,
7926            &binding,
7927            &snapshot.agent_id,
7928            &current_root,
7929            allow_clean_detachment,
7930        ) || !session_record_context_export_source_is_usable(
7931            &current,
7932            &snapshot.status,
7933            allow_clean_detachment,
7934        ) {
7935            return Err(failure(
7936                NodeFailureCode::SessionRecordConflict,
7937                "managed session binding changed during context export",
7938            ));
7939        }
7940        Ok(())
7941    }
7942
7943    fn revalidate_loaded_history(
7944        &self,
7945        session: &SessionAddress,
7946        candidate_id: &str,
7947        loaded: &HistorySessionRecord,
7948    ) -> Result<(), NodeFailure> {
7949        let snapshot = self.internal_session_snapshot(session)?;
7950        if snapshot.history.pending.is_some()
7951            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7952            || snapshot.history.loaded.as_ref() != Some(loaded)
7953        {
7954            return Err(failure(
7955                NodeFailureCode::SessionRecordConflict,
7956                "managed session history changed during context export",
7957            ));
7958        }
7959        Ok(())
7960    }
7961
7962    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7963        self.session_records
7964            .lock()
7965            .unwrap_or_else(|poisoned| poisoned.into_inner())
7966            .records
7967            .remove(record_id)
7968    }
7969
7970    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7971        if self.destroy_record_materialization(record_id)? {
7972            self.publish(NodeEvent::SessionRecordRemoved {
7973                record_id: record_id.clone(),
7974            });
7975            return Ok(());
7976        }
7977        let transaction = self
7978            .state_transaction
7979            .lock()
7980            .unwrap_or_else(|poisoned| poisoned.into_inner());
7981        let Some(removed) = self.remove_record_memory(record_id) else {
7982            return Ok(());
7983        };
7984        if let Err(error) = self.persist_state_locked() {
7985            let _ = self.insert_record(removed);
7986            return Err(persistence_failure(error));
7987        }
7988        drop(transaction);
7989        self.publish(NodeEvent::SessionRecordRemoved {
7990            record_id: record_id.clone(),
7991        });
7992        Ok(())
7993    }
7994
7995    fn publish_record(&self, record: ManagedSessionRecord) {
7996        self.publish(NodeEvent::SessionRecordUpserted { record });
7997    }
7998
7999    fn mark_record_error(
8000        &self,
8001        record_id: &SessionRecordId,
8002        message: &str,
8003    ) -> Result<(), NodeFailure> {
8004        let transaction = self
8005            .state_transaction
8006            .lock()
8007            .unwrap_or_else(|poisoned| poisoned.into_inner());
8008        let previous = {
8009            let mut records = self
8010                .session_records
8011                .lock()
8012                .unwrap_or_else(|poisoned| poisoned.into_inner());
8013            let Some(record) = records.records.get_mut(record_id) else {
8014                return Ok(());
8015            };
8016            let previous = record.clone();
8017            record.state = ManagedSessionState::Unavailable;
8018            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8019            record.updated_at_unix_ms = unix_time_ms();
8020            previous
8021        };
8022        if let Err(error) = self.persist_state_locked() {
8023            self.session_records
8024                .lock()
8025                .unwrap_or_else(|poisoned| poisoned.into_inner())
8026                .records
8027                .insert(record_id.clone(), previous);
8028            return Err(persistence_failure(error));
8029        }
8030        let record = self.record(record_id)?;
8031        drop(transaction);
8032        self.publish_record(record);
8033        Ok(())
8034    }
8035
8036    fn rename_session_record(
8037        &self,
8038        record_id: &SessionRecordId,
8039        display_name: String,
8040    ) -> Result<ManagedSessionRecord, NodeFailure> {
8041        validate_display_name(&display_name)
8042            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8043        let _transaction = self
8044            .state_transaction
8045            .lock()
8046            .unwrap_or_else(|poisoned| poisoned.into_inner());
8047        let previous = {
8048            let mut records = self
8049                .session_records
8050                .lock()
8051                .unwrap_or_else(|poisoned| poisoned.into_inner());
8052            let record = records.records.get_mut(record_id).ok_or_else(|| {
8053                failure(
8054                    NodeFailureCode::UnknownSessionRecord,
8055                    "managed session record does not exist",
8056                )
8057            })?;
8058            let previous = record.clone();
8059            record.display_name = display_name;
8060            record.updated_at_unix_ms = unix_time_ms();
8061            previous
8062        };
8063        if let Err(error) = self.persist_state_locked() {
8064            self.session_records
8065                .lock()
8066                .unwrap_or_else(|poisoned| poisoned.into_inner())
8067                .records
8068                .insert(record_id.clone(), previous);
8069            return Err(persistence_failure(error));
8070        }
8071        let updated = self.record(record_id)?;
8072        self.publish_record(updated.clone());
8073        Ok(updated)
8074    }
8075
8076    fn set_session_task(
8077        &self,
8078        record_id: &SessionRecordId,
8079        expected_revision: u64,
8080        target: SessionTaskTargetV1,
8081    ) -> Result<ManagedSessionRecord, NodeFailure> {
8082        let transaction = self
8083            .state_transaction
8084            .lock()
8085            .unwrap_or_else(|poisoned| poisoned.into_inner());
8086        let current = self.record(record_id)?;
8087        let current_task_id = current
8088            .task_binding
8089            .as_ref()
8090            .and_then(|binding| binding.task_id.as_ref());
8091        let current_revision = current
8092            .task_binding
8093            .as_ref()
8094            .map_or(0, |binding| binding.revision);
8095        if expected_revision != current_revision {
8096            return Err(failure(
8097                NodeFailureCode::SessionRecordConflict,
8098                "managed session task binding revision changed",
8099            ));
8100        }
8101        let exact = match &target {
8102            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8103            SessionTaskTargetV1::Clear => current_task_id.is_none(),
8104            SessionTaskTargetV1::New => false,
8105        };
8106        if exact {
8107            return Ok(current);
8108        }
8109        let revision = current_revision.checked_add(1).ok_or_else(|| {
8110            failure(
8111                NodeFailureCode::SessionRecordConflict,
8112                "managed session task binding revision is exhausted",
8113            )
8114        })?;
8115        let task_id = match target {
8116            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8117            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8118            SessionTaskTargetV1::Clear => None,
8119        };
8120        let changed_at_unix_ms = unix_time_ms()
8121            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8122                failure(
8123                    NodeFailureCode::SessionRecordConflict,
8124                    "managed session record timestamp is exhausted",
8125                )
8126            })?);
8127        let updated = {
8128            let mut records = self
8129                .session_records
8130                .lock()
8131                .unwrap_or_else(|poisoned| poisoned.into_inner());
8132            let record = records.records.get_mut(record_id).ok_or_else(|| {
8133                failure(
8134                    NodeFailureCode::UnknownSessionRecord,
8135                    "managed session record does not exist",
8136                )
8137            })?;
8138            record.task_binding = Some(SessionTaskBindingV1 {
8139                revision,
8140                task_id,
8141                changed_at_unix_ms,
8142            });
8143            record.updated_at_unix_ms = changed_at_unix_ms;
8144            record.clone()
8145        };
8146        if let Err(error) = self.persist_state_locked() {
8147            self.session_records
8148                .lock()
8149                .unwrap_or_else(|poisoned| poisoned.into_inner())
8150                .records
8151                .insert(record_id.clone(), current);
8152            return Err(persistence_failure(error));
8153        }
8154        drop(transaction);
8155        self.publish_record(updated.clone());
8156        Ok(updated)
8157    }
8158
8159    fn index_provider_session(
8160        &self,
8161        workspace_id: WorkspaceId,
8162        provider: AgentId,
8163        identity: ProviderSessionIdentity,
8164        display_name: String,
8165    ) -> Result<ManagedSessionRecord, NodeFailure> {
8166        self.index_provider_session_with_policy(
8167            workspace_id,
8168            provider,
8169            identity,
8170            display_name,
8171            false,
8172        )
8173    }
8174
8175    fn index_provider_session_with_policy(
8176        &self,
8177        workspace_id: WorkspaceId,
8178        provider: AgentId,
8179        identity: ProviderSessionIdentity,
8180        display_name: String,
8181        allow_validated_transcript_path: bool,
8182    ) -> Result<ManagedSessionRecord, NodeFailure> {
8183        validate_display_name(&display_name)
8184            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8185        identity
8186            .validate()
8187            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8188        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8189            return Err(failure(
8190                NodeFailureCode::InvalidRequest,
8191                "provider session reference index v1 does not accept transcript paths",
8192            ));
8193        }
8194        let canonical_root = self.workspace_root(&workspace_id)?;
8195        if !self.enabled_providers.contains(&provider) {
8196            return Err(failure(
8197                NodeFailureCode::InvalidRequest,
8198                "provider is not enabled on this node",
8199            ));
8200        }
8201
8202        let transaction = self
8203            .state_transaction
8204            .lock()
8205            .unwrap_or_else(|poisoned| poisoned.into_inner());
8206        {
8207            let records = self
8208                .session_records
8209                .lock()
8210                .unwrap_or_else(|poisoned| poisoned.into_inner());
8211            if let Some(existing) = records.records.values().find(|candidate| {
8212                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8213                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
8214                })
8215            }) {
8216                if existing.provider == provider && existing.workspace_id == workspace_id {
8217                    return Ok(existing.clone());
8218                }
8219                return Err(failure(
8220                    NodeFailureCode::SessionRecordConflict,
8221                    "provider session reference conflicts with another provider or workspace",
8222                ));
8223            }
8224            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8225                return Err(failure(
8226                    NodeFailureCode::BackendBusy,
8227                    "managed session record capacity is full; forget an inactive record first",
8228                ));
8229            }
8230        }
8231
8232        let now = unix_time_ms();
8233        let record = ManagedSessionRecord {
8234            record_id: self.allocate_record_id()?,
8235            display_name,
8236            provider,
8237            mode: SessionMode::Pty,
8238            state: ManagedSessionState::Dormant,
8239            workspace_id,
8240            canonical_root: opaque_windows_path(canonical_root),
8241            provider_session: Some(identity),
8242            active_session: None,
8243            environment_profile: None,
8244            bundle: None,
8245            context_id: None,
8246            context: None,
8247            exported_context: None,
8248            task_binding: None,
8249            created_at_unix_ms: now,
8250            updated_at_unix_ms: now,
8251            last_error: None,
8252        };
8253        self.insert_record(record.clone())?;
8254        if let Err(error) = self.persist_state_locked() {
8255            self.remove_record_memory(&record.record_id);
8256            return Err(persistence_failure(error));
8257        }
8258        drop(transaction);
8259        self.publish_record(record.clone());
8260        Ok(record)
8261    }
8262
8263    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8264        self.session_bindings
8265            .lock()
8266            .unwrap_or_else(|poisoned| poisoned.into_inner())
8267            .iter()
8268            .find_map(|(instance_id, binding)| {
8269                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8270                    workspace_id: binding.workspace_id.clone(),
8271                    session: SessionKey {
8272                        instance_id: *instance_id,
8273                        generation: binding.generation,
8274                    },
8275                })
8276            })
8277    }
8278
8279    async fn forget_session_record(
8280        &self,
8281        record_id: &SessionRecordId,
8282    ) -> Result<(), NodeFailure> {
8283        let record = self.record(record_id)?;
8284        if record.active_session.is_some()
8285            || matches!(
8286                record.state,
8287                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8288            )
8289        {
8290            return Err(failure(
8291                NodeFailureCode::SessionRecordBusy,
8292                "stop the managed session before forgetting it",
8293            ));
8294        }
8295        if let Some(address) = self.bound_address_for_record(record_id) {
8296            self.remove_session(&address).await?;
8297        }
8298        self.remove_managed_session_record(record_id, &record.workspace_id).await
8299    }
8300
8301    /// Removes one managed session record from durable state and notifies
8302    /// every listener -- the shared transaction body behind both
8303    /// `forget_session_record`'s operator-driven path (busy-checked above
8304    /// it, and clears any still-live session binding before calling in
8305    /// here) and the retention sweep (`retire_unavailable_session_records`,
8306    /// which only ever selects `ManagedSessionState::Unavailable` records
8307    /// that are already known never-busy and never bound, so it calls
8308    /// straight into this with neither check). Cleans a managed-worktree
8309    /// lease if the record held one, removes the record's environment
8310    /// materialization (or, absent one, removes it from memory directly),
8311    /// persists under `state_transaction` with rollback on persist failure,
8312    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8313    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8314    /// exactly as it does for an operator-driven forget.
8315    async fn remove_managed_session_record(
8316        &self,
8317        record_id: &SessionRecordId,
8318        workspace_id: &WorkspaceId,
8319    ) -> Result<(), NodeFailure> {
8320        let managed_lease_id = self.managed_worktrees.lock()
8321            .unwrap_or_else(|poisoned| poisoned.into_inner())
8322            .lease_for_workspace(workspace_id);
8323        if !self.destroy_record_materialization(record_id)? {
8324            let transaction = self
8325                .state_transaction
8326                .lock()
8327                .unwrap_or_else(|poisoned| poisoned.into_inner());
8328            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8329                failure(
8330                    NodeFailureCode::UnknownSessionRecord,
8331                    "managed session record does not exist",
8332                )
8333            })?;
8334            if let Err(error) = self.persist_state_locked() {
8335                let _ = self.insert_record(removed);
8336                return Err(persistence_failure(error));
8337            }
8338            drop(transaction);
8339        }
8340        self.publish(NodeEvent::SessionRecordRemoved {
8341            record_id: record_id.clone(),
8342        });
8343        if let Some(lease_id) = managed_lease_id {
8344            if let Some(snapshot) = self.managed_worktrees.lock()
8345                .unwrap_or_else(|poisoned| poisoned.into_inner())
8346                .get(&lease_id)
8347                .map(ManagedWorktreeLeaseRecord::snapshot)
8348            {
8349                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8350            }
8351            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8352        }
8353        Ok(())
8354    }
8355
8356    /// Retires `ManagedSessionState::Unavailable` records against the
8357    /// node's configured `session_record_retention` age/keep-N policy.
8358    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8359    /// `Dormant` is resumable and folding it into this sweep would delete
8360    /// records a caller can still act on. Both policy fields default to
8361    /// `0` (disabled); when both are `0` this is a no-op. When both are
8362    /// set, a record is retired only if it fails BOTH tests (older than
8363    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8364    /// workspace); when only one is set, that one alone decides. Called
8365    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8366    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8367    /// loop -- see both call sites.
8368    async fn retire_unavailable_session_records(&self) {
8369        let policy = self.session_record_retention;
8370        let age_enabled = policy.age_ms > 0;
8371        let keep_enabled = policy.keep_per_workspace > 0;
8372        if !age_enabled && !keep_enabled {
8373            return;
8374        }
8375        let now = unix_time_ms();
8376        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8377            self.session_records
8378                .lock()
8379                .unwrap_or_else(|poisoned| poisoned.into_inner())
8380                .records
8381                .values()
8382                .filter(|record| record.state == ManagedSessionState::Unavailable)
8383                .map(|record| {
8384                    (
8385                        record.record_id.clone(),
8386                        record.workspace_id.clone(),
8387                        record.provider.clone(),
8388                        record.updated_at_unix_ms,
8389                    )
8390                })
8391                .collect()
8392        };
8393        if candidates.is_empty() {
8394            return;
8395        }
8396        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8397            BTreeMap::new();
8398        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8399            by_workspace
8400                .entry(workspace_id)
8401                .or_default()
8402                .push((record_id, provider, updated_at_unix_ms));
8403        }
8404        let reason = if age_enabled && keep_enabled {
8405            "age+keep-n"
8406        } else if age_enabled {
8407            "age"
8408        } else {
8409            "keep-n"
8410        };
8411        let mut retired: u64 = 0;
8412        for (workspace_id, mut records) in by_workspace {
8413            // Newest `updated_at_unix_ms` first, so index `>=
8414            // keep_per_workspace` names exactly "beyond the newest N".
8415            records.sort_by(|left, right| right.2.cmp(&left.2));
8416            for (index, (record_id, provider, updated_at_unix_ms)) in
8417                records.into_iter().enumerate()
8418            {
8419                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8420                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8421                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8422                let should_retire = if age_enabled && keep_enabled {
8423                    age_hit && keep_hit
8424                } else {
8425                    age_hit || keep_hit
8426                };
8427                if !should_retire {
8428                    continue;
8429                }
8430                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8431                    Ok(()) => {
8432                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8433                        retired += 1;
8434                        tracing::info!(
8435                            node_id = %self.node_id,
8436                            record_id = %record_id,
8437                            workspace_id = %workspace_id,
8438                            provider = %provider,
8439                            age_ms = record_age_ms,
8440                            reason,
8441                            "managed session record retired"
8442                        );
8443                    }
8444                    Err(error) => tracing::warn!(
8445                        node_id = %self.node_id,
8446                        record_id = %record_id,
8447                        workspace_id = %workspace_id,
8448                        code = ?error.code,
8449                        cause = %error.message,
8450                        "session record retention sweep failed to retire a record"
8451                    ),
8452                }
8453            }
8454        }
8455        if retired > 0 {
8456            let remaining_unavailable = self
8457                .session_records
8458                .lock()
8459                .unwrap_or_else(|poisoned| poisoned.into_inner())
8460                .records
8461                .values()
8462                .filter(|record| record.state == ManagedSessionState::Unavailable)
8463                .count();
8464            tracing::info!(
8465                node_id = %self.node_id,
8466                retired,
8467                remaining_unavailable,
8468                "session record retention sweep completed"
8469            );
8470        }
8471    }
8472
8473    async fn resume_session_record(
8474        &self,
8475        record_id: &SessionRecordId,
8476        terminal_size: gate4agent_types::TerminalSize,
8477        initial_prompt: Option<String>,
8478    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8479        let record = self.record(record_id)?;
8480        if record.active_session.is_some()
8481            || matches!(
8482                record.state,
8483                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8484            )
8485        {
8486            return Err(failure(
8487                NodeFailureCode::SessionRecordBusy,
8488                "managed session already has a live runtime binding",
8489            ));
8490        }
8491        let identity = record.provider_session.clone().ok_or_else(|| {
8492            failure(
8493                NodeFailureCode::SessionRecordNotResumable,
8494                "managed session has no verified provider session identity",
8495            )
8496        })?;
8497        let working_directory = self.workspace_root(&record.workspace_id)?;
8498        if !platform::roots_equal(
8499            &working_directory,
8500            windows_path_text(&record.canonical_root),
8501        ) {
8502            return Err(failure(
8503                NodeFailureCode::SessionRecordConflict,
8504                "managed session workspace root changed; refusing to resume in another directory",
8505            ));
8506        }
8507        {
8508            let records = self
8509                .session_records
8510                .lock()
8511                .unwrap_or_else(|poisoned| poisoned.into_inner());
8512            if records.records.values().any(|candidate| {
8513                candidate.record_id != *record_id
8514                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8515                        session_registry::same_provider_session(
8516                            &candidate.provider,
8517                            candidate_identity,
8518                            &record.provider,
8519                            &identity,
8520                        )
8521                    })
8522                    && (candidate.active_session.is_some()
8523                        || matches!(
8524                            candidate.state,
8525                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8526                        ))
8527            }) {
8528                return Err(failure(
8529                    NodeFailureCode::SessionRecordConflict,
8530                    "provider session is already active through another managed record",
8531                ));
8532            }
8533        }
8534        let runtime_requirement = if initial_prompt.is_some() {
8535            ProviderRuntimeRequirement::ResumeWithPrompt
8536        } else {
8537            ProviderRuntimeRequirement::Resume
8538        };
8539        let runtime_policy = self.admit_provider_runtime(
8540            &record.provider,
8541            runtime_requirement,
8542        )
8543        .await?;
8544        let request = ResumeLaunchRequest {
8545            working_directory,
8546            terminal_size,
8547            initial_prompt,
8548        };
8549        request
8550            .validate()
8551            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8552        self.ensure_binding_capacity()?;
8553        let resolved_materialization = match self.resolve_record_materialization(
8554            record_id,
8555            &record.provider,
8556            record.mode,
8557            record.environment_profile.as_ref(),
8558            record.bundle.as_ref(),
8559            record.context.as_ref(),
8560        ) {
8561            Ok(materialization) => materialization,
8562            Err(error) => {
8563                let label = match error.code {
8564                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8565                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8566                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8567                    _ => "environment-profile-unavailable",
8568                };
8569                self.mark_record_error(record_id, label)?;
8570                return Err(error);
8571            }
8572        };
8573        let (materialization_id, environment_overlay) = match resolved_materialization {
8574            Some((id, overlay)) => (Some(id), overlay),
8575            None => (None, None),
8576        };
8577        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8578        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8579            instance_id,
8580            &record.provider,
8581            record.mode,
8582            record.environment_profile.as_ref(),
8583        ) {
8584            Ok(selection) => selection,
8585            Err(error) => {
8586                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8587                return Err(error);
8588            }
8589        };
8590        let mut instance_overlay = environment_overlay
8591            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8592            .transpose()?
8593            .flatten();
8594        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8595            self.remove_session(&stale_address).await?;
8596        }
8597        let address = SessionAddress {
8598            workspace_id: record.workspace_id.clone(),
8599            session: SessionKey {
8600                instance_id,
8601                generation: SessionGeneration(1),
8602            },
8603        };
8604        let transaction = self
8605            .state_transaction
8606            .lock()
8607            .unwrap_or_else(|poisoned| poisoned.into_inner());
8608        let current = self.record(record_id)?;
8609        if current.active_session.is_some()
8610            || matches!(
8611                current.state,
8612                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8613            )
8614        {
8615            return Err(failure(
8616                NodeFailureCode::SessionRecordBusy,
8617                "managed session became active while resume was being prepared",
8618            ));
8619        }
8620        self.bind_managed_session_with_materialization(
8621            &address,
8622            record_id.clone(),
8623            runtime_policy,
8624            record.environment_profile.clone(),
8625            record.bundle.clone(),
8626            record.context.clone(),
8627            materialization_id,
8628        );
8629        if let Some(selection) = environment_selection.take() {
8630            selection.retain();
8631        }
8632        if let Some(overlay) = instance_overlay.take() {
8633            overlay.retain();
8634        }
8635        drop(transaction);
8636
8637        let transport = match record.mode {
8638            SessionMode::Pty => TransportKind::Pty,
8639            SessionMode::Inline => TransportKind::Pipe,
8640            SessionMode::Acp => TransportKind::Acp,
8641        };
8642        let agent_id = record.provider.clone();
8643        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8644        if let Err(error) = self
8645            .dispatch_bounded(
8646                ControlCommand::Register {
8647                    instance_id,
8648                    agent_id,
8649                    transport,
8650                },
8651                dispatch_timeout,
8652            )
8653            .await
8654        {
8655            if let Some(binding) = self.remove_binding(&address) {
8656                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8657            }
8658            return Err(error);
8659        }
8660        let command = self.prepare_command(ControlCommand::Resume {
8661            instance_id,
8662            target: ResumeTarget::ProviderSession { identity },
8663            runtime_policy,
8664            request,
8665        });
8666        let started_after = self.current_sequence();
8667        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8668            self.rollback_spawn(&address, dispatch_timeout).await?;
8669            return Err(error);
8670        }
8671        if let Err(error) = self.dispatch_envelope(command) {
8672            self.clear_armed_resume(&address);
8673            self.rollback_spawn(&address, dispatch_timeout).await?;
8674            return Err(error);
8675        }
8676        match self
8677            .wait_for_record_resume(&address, started_after)
8678            .await
8679        {
8680            Ok(settled_address) => {
8681                let managed_lease_id = {
8682                    self.managed_worktrees.lock()
8683                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8684                        .lease_for_workspace(&record.workspace_id)
8685                };
8686                if let Some(lease_id) = managed_lease_id {
8687                    {
8688                        let mut bindings = self.session_bindings.lock()
8689                            .unwrap_or_else(|poisoned| poisoned.into_inner());
8690                        let binding = bindings.get_mut(&settled_address.session.instance_id)
8691                            .ok_or_else(|| failure(
8692                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8693                                "resumed managed session lost its exact binding",
8694                            ))?;
8695                        if binding.generation != settled_address.session.generation {
8696                            return Err(failure(
8697                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8698                                "resumed managed session generation diverged",
8699                            ));
8700                        }
8701                        binding.managed_worktree_lease_id = Some(lease_id.clone());
8702                    }
8703                    let snapshot = self.managed_worktrees.lock()
8704                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8705                        .bind_session(
8706                            &lease_id,
8707                            ManagedWorktreeSessionHolder {
8708                                incarnation_id: self.incarnation_id,
8709                                instance_id: settled_address.session.instance_id,
8710                                generation: settled_address.session.generation,
8711                            },
8712                            Some(record_id.clone()),
8713                            unix_time_ms(),
8714                        ).map_err(|message| failure(
8715                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
8716                            &message,
8717                        ))?;
8718                    if let Err(error) = self.persist_state() {
8719                        let _ = self.remove_session(&settled_address).await;
8720                        return Err(persistence_failure(error));
8721                    }
8722                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8723                }
8724                Ok((self.record(record_id)?, settled_address))
8725            }
8726            Err(error) => {
8727                if matches!(
8728                    error.code,
8729                    NodeFailureCode::SessionRecordNotResumable
8730                        | NodeFailureCode::BackendOperationFailed
8731                ) {
8732                    if let Some(bound) = self.bound_address_for_record(record_id) {
8733                        let _ = self.remove_session(&bound).await;
8734                    }
8735                }
8736                Err(error)
8737            }
8738        }
8739    }
8740
8741    async fn wait_for_record_resume(
8742        &self,
8743        initial_address: &SessionAddress,
8744        after_sequence: u64,
8745    ) -> Result<SessionAddress, NodeFailure> {
8746        let deadline = Instant::now()
8747            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8748        let mut scan_after = after_sequence;
8749        loop {
8750            let events = self
8751                .history
8752                .lock()
8753                .unwrap_or_else(|poisoned| poisoned.into_inner())
8754                .events
8755                .iter()
8756                .filter(|event| event.sequence > scan_after)
8757                .cloned()
8758                .collect::<Vec<_>>();
8759            for envelope in events {
8760                scan_after = scan_after.max(envelope.sequence);
8761                let NodeEvent::Control { address, event } = envelope.event else {
8762                    continue;
8763                };
8764                if address.session.instance_id != initial_address.session.instance_id {
8765                    continue;
8766                }
8767                match event.event {
8768                    ControlEventKind::Resumed { .. } => return Ok(address),
8769                    ControlEventKind::ResumeDenied { reason } => {
8770                        return Err(failure(
8771                            NodeFailureCode::SessionRecordNotResumable,
8772                            &reason,
8773                        ));
8774                    }
8775                    ControlEventKind::ResumeFailed { message }
8776                    | ControlEventKind::CommandRejected { message } => {
8777                        return Err(failure(
8778                            NodeFailureCode::BackendOperationFailed,
8779                            &message,
8780                        ));
8781                    }
8782                    _ => {}
8783                }
8784            }
8785            if Instant::now() >= deadline {
8786                return Err(failure(
8787                    NodeFailureCode::BackendBusy,
8788                    "managed resume did not settle before the bounded deadline",
8789                ));
8790            }
8791            sleep(Duration::from_millis(5)).await;
8792        }
8793    }
8794
8795    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8796        self.workspaces
8797            .read()
8798            .unwrap_or_else(|poisoned| poisoned.into_inner())
8799            .get(workspace_id)
8800            .cloned()
8801            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8802    }
8803
8804    fn workspace_roots(&self) -> Vec<PathBuf> {
8805        self.workspaces
8806            .read()
8807            .unwrap_or_else(|poisoned| poisoned.into_inner())
8808            .values()
8809            .map(PathBuf::from)
8810            .collect()
8811    }
8812
8813    async fn inspect_workspace(
8814        &self,
8815        workspace_id: WorkspaceId,
8816    ) -> Result<WorkspaceInspection, NodeFailure> {
8817        let _permit = self
8818            .inspection_slots
8819            .clone()
8820            .try_acquire_owned()
8821            .map_err(|_| {
8822                tracing::warn!(
8823                    workspace_id = %workspace_id,
8824                    "workspace inspection rejected: inspection capacity is busy",
8825                );
8826                failure(
8827                    NodeFailureCode::BackendBusy,
8828                    "workspace inspection capacity is busy",
8829                )
8830            })?;
8831        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8832            tracing::warn!(
8833                workspace_id = %workspace_id,
8834                code = ?error.code,
8835                "workspace inspection rejected: workspace is not registered",
8836            );
8837            error
8838        })?;
8839        let tree_root = canonical_root.clone();
8840        let started_at = Instant::now();
8841        let time_budget_ms = workspace_inspection_time_budget_ms();
8842        let entry_cap = workspace_inspection_entry_cap();
8843        let deadline = started_at + Duration::from_millis(time_budget_ms);
8844        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8845            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8846        })
8847        .await
8848        .map_err(|error| {
8849            tracing::warn!(
8850                workspace_id = %workspace_id,
8851                error = %error,
8852                "workspace inspection rejected: walk task failed",
8853            );
8854            failure(
8855                NodeFailureCode::BackendOperationFailed,
8856                &format!("workspace inspection task failed: {error}"),
8857            )
8858        })?;
8859        let (mut git, git_time_budget_exceeded) =
8860            inspect_git_workspace(&canonical_root, deadline).await;
8861        if !git.worktrees.is_empty() {
8862            let registered = self
8863                .workspaces
8864                .read()
8865                .unwrap_or_else(|poisoned| poisoned.into_inner())
8866                .iter()
8867                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8868                .collect::<Vec<_>>();
8869            for worktree in &mut git.worktrees {
8870                worktree.workspace_id = registered
8871                    .iter()
8872                    .find(|(_, root)| {
8873                        worktree_paths_equal(root, windows_path_text(&worktree.path))
8874                    })
8875                    .map(|(workspace_id, _)| workspace_id.clone());
8876            }
8877        }
8878        if git.is_repository {
8879            git.managed_worktree = self
8880                .managed_worktrees
8881                .lock()
8882                .unwrap_or_else(|poisoned| poisoned.into_inner())
8883                .git_scope(&workspace_id, git.branch.as_deref());
8884        }
8885        let truncation = workspace_inspection_truncation(
8886            &walk_budget,
8887            git_time_budget_exceeded,
8888            started_at.elapsed(),
8889        );
8890        if let Some(truncation) = &truncation {
8891            tracing::info!(
8892                workspace_id = %workspace_id,
8893                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8894                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8895                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8896                entries_visited = truncation.entries_visited,
8897                elapsed_ms = truncation.elapsed_ms,
8898                "workspace inspection truncated by its inner budget",
8899            );
8900        }
8901        Ok(WorkspaceInspection {
8902            workspace_id,
8903            entries,
8904            tree_truncated,
8905            git,
8906            truncation,
8907        })
8908    }
8909
8910    async fn browse_host_directories(
8911        &self,
8912        directory: Option<OpaqueHostPath>,
8913        after: Option<OpaqueHostPath>,
8914    ) -> Result<HostDirectoryListing, NodeFailure> {
8915        let permit = self
8916            .inspection_slots
8917            .clone()
8918            .try_acquire_owned()
8919            .map_err(|_| {
8920                failure(
8921                    NodeFailureCode::BackendBusy,
8922                    "host directory browse capacity is busy",
8923                )
8924            })?;
8925        let task = tokio::task::spawn_blocking(move || {
8926            let _permit = permit;
8927            browse_host_directories(directory, after)
8928        });
8929        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8930            .await
8931            .map_err(|_| {
8932                failure(
8933                    NodeFailureCode::HostDirectoryReadTimedOut,
8934                    "host directory browse exceeded its bounded deadline",
8935                )
8936            })?
8937            .map_err(|_| {
8938                failure(
8939                    NodeFailureCode::HostDirectoryReadFailed,
8940                    "host directory browse task failed",
8941                )
8942            })?
8943            .map_err(host_directory_failure)
8944    }
8945
8946    async fn read_workspace_file(
8947        &self,
8948        workspace_id: WorkspaceId,
8949        path: RepositoryPath,
8950    ) -> Result<WorkspaceFileRead, NodeFailure> {
8951        let canonical_root = self.workspace_root(&workspace_id)?;
8952        let permit = self
8953            .inspection_slots
8954            .clone()
8955            .try_acquire_owned()
8956            .map_err(|_| {
8957                failure(
8958                    NodeFailureCode::BackendBusy,
8959                    "workspace file read capacity is busy",
8960                )
8961            })?;
8962        let path_to_read = path.clone();
8963        let task = tokio::task::spawn_blocking(move || {
8964            let _permit = permit;
8965            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8966        });
8967        let content = timeout(
8968            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8969            task,
8970        )
8971        .await
8972        .map_err(|_| {
8973            failure(
8974                NodeFailureCode::RepositoryFileReadTimedOut,
8975                "repository file read exceeded its bounded deadline",
8976            )
8977        })?
8978        .map_err(|_| {
8979            failure(
8980                NodeFailureCode::RepositoryFileReadFailed,
8981                "repository file read task failed",
8982            )
8983        })?
8984        .map_err(workspace_file_failure)?;
8985        let content = match content {
8986            WorkspaceFileBytes::Utf8(text) => {
8987                let revision = Some(workspace_file_revision(text.as_bytes()));
8988                return Ok(WorkspaceFileRead {
8989                    workspace_id,
8990                    path,
8991                    content: WorkspaceFileContent::Utf8 {
8992                        byte_len: u32::try_from(text.len())
8993                            .expect("bounded workspace text length must fit u32"),
8994                        text,
8995                    },
8996                    revision,
8997                });
8998            }
8999            WorkspaceFileBytes::NonUtf8 { byte_length } => {
9000                WorkspaceFileContent::NonUtf8 {
9001                    byte_len: u32::try_from(byte_length)
9002                        .expect("bounded workspace file length must fit u32"),
9003                }
9004            }
9005            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9006                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9007            },
9008        };
9009        Ok(WorkspaceFileRead {
9010            workspace_id,
9011            path,
9012            content,
9013            revision: None,
9014        })
9015    }
9016
9017    async fn write_workspace_file(
9018        &self,
9019        workspace_id: WorkspaceId,
9020        path: RepositoryPath,
9021        expected_revision: WorkspaceFileRevision,
9022        text: String,
9023    ) -> Result<WorkspaceFileRead, NodeFailure> {
9024        let canonical_root = self.workspace_root(&workspace_id)?;
9025        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9026            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9027        })?;
9028        let path_to_write = path.clone();
9029        let expected = expected_revision.as_str().to_owned();
9030        let text_to_write = text.clone();
9031        let task = tokio::task::spawn_blocking(move || {
9032            let _permit = permit;
9033            write_workspace_file_to_disk(
9034                Path::new(&canonical_root),
9035                &path_to_write,
9036                &expected,
9037                &text_to_write,
9038            )
9039        });
9040        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9041            .await
9042            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9043            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9044            .map_err(workspace_file_write_failure)?;
9045        Ok(WorkspaceFileRead {
9046            workspace_id,
9047            path,
9048            content: WorkspaceFileContent::Utf8 {
9049                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9050                text,
9051            },
9052            revision: Some(WorkspaceFileRevision::new(revision)
9053                .expect("workspace writer returns a valid SHA-256 revision")),
9054        })
9055    }
9056
9057    async fn create_workspace_file(
9058        &self,
9059        workspace_id: WorkspaceId,
9060        path: RepositoryPath,
9061    ) -> Result<WorkspaceFileRead, NodeFailure> {
9062        let canonical_root = self.workspace_root(&workspace_id)?;
9063        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9064            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9065        })?;
9066        let path_to_create = path.clone();
9067        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9068        let task_commit_state = Arc::clone(&commit_state);
9069        let task = tokio::task::spawn_blocking(move || {
9070            let _permit = permit;
9071            create_workspace_file_on_disk(
9072                Path::new(&canonical_root),
9073                &path_to_create,
9074                &task_commit_state,
9075            )
9076        });
9077        let revision = settle_workspace_entry_create(
9078            task,
9079            commit_state,
9080            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9081            "repository file create exceeded its bounded deadline",
9082            "repository file create task failed",
9083        )
9084        .await?;
9085        Ok(WorkspaceFileRead {
9086            workspace_id,
9087            path,
9088            content: WorkspaceFileContent::Utf8 {
9089                text: String::new(),
9090                byte_len: 0,
9091            },
9092            revision: Some(WorkspaceFileRevision::new(revision)
9093                .expect("workspace creator returns a valid SHA-256 revision")),
9094        })
9095    }
9096
9097    async fn create_workspace_directory(
9098        &self,
9099        workspace_id: WorkspaceId,
9100        path: RepositoryPath,
9101    ) -> Result<WorkspaceEntry, NodeFailure> {
9102        let canonical_root = self.workspace_root(&workspace_id)?;
9103        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9104            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9105        })?;
9106        let path_to_create = path.clone();
9107        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9108        let task_commit_state = Arc::clone(&commit_state);
9109        let task = tokio::task::spawn_blocking(move || {
9110            let _permit = permit;
9111            create_workspace_directory_on_disk(
9112                Path::new(&canonical_root),
9113                &path_to_create,
9114                &task_commit_state,
9115            )
9116        });
9117        settle_workspace_entry_create(
9118            task,
9119            commit_state,
9120            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9121            "repository directory create exceeded its bounded deadline",
9122            "repository directory create task failed",
9123        )
9124        .await?;
9125        Ok(WorkspaceEntry {
9126            relative_path: path,
9127            kind: WorkspaceEntryKind::Directory,
9128        })
9129    }
9130
9131    async fn read_git_history(
9132        &self,
9133        workspace_id: WorkspaceId,
9134        path: Option<RepositoryPath>,
9135        before: Option<GitObjectId>,
9136        limit: u16,
9137    ) -> Result<GitHistoryPage, NodeFailure> {
9138        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9139            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9140        })?;
9141        let root = self.workspace_root(&workspace_id)?;
9142        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9143    }
9144
9145    async fn read_git_diff(
9146        &self,
9147        workspace_id: WorkspaceId,
9148        request: GitDiffRequest,
9149    ) -> Result<GitDiff, NodeFailure> {
9150        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9151            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9152        })?;
9153        let root = self.workspace_root(&workspace_id)?;
9154        read_git_diff_bounded(&root, request).await
9155    }
9156
9157    fn native_session_route_root(
9158        &self,
9159        route: &NativeSessionCatalogRoute,
9160    ) -> Result<Option<String>, NodeFailure> {
9161        route
9162            .validate()
9163            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9164        match route.scope {
9165            gate4agent_types::NativeSessionCatalogScope::Workspace => route
9166                .workspace_id
9167                .as_ref()
9168                .map(|workspace_id| self.workspace_root(workspace_id))
9169                .transpose(),
9170            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9171        }
9172    }
9173
9174    fn project_native_session_entries(
9175        &self,
9176        route: &NativeSessionCatalogRoute,
9177        entries: Vec<ScopedNativeSessionCatalogEntry>,
9178    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9179        let current_root = route
9180            .workspace_id
9181            .as_ref()
9182            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9183        let records = self
9184            .session_records
9185            .lock()
9186            .unwrap_or_else(|poisoned| poisoned.into_inner());
9187        let mut seen_records = std::collections::HashSet::new();
9188        entries
9189            .into_iter()
9190            .map(|entry| {
9191                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9192                    let matches = records
9193                        .records
9194                        .values()
9195                        .filter(|record| {
9196                            record.provider == route.provider
9197                                && record.workspace_id == *workspace_id
9198                                && current_root.as_deref().is_some_and(|root| {
9199                                    platform::roots_equal(
9200                                        root,
9201                                        windows_path_text(&record.canonical_root),
9202                                    )
9203                                })
9204                                && record.provider_session.as_ref().is_some_and(|identity| {
9205                                    session_registry::same_provider_session(
9206                                        &record.provider,
9207                                        identity,
9208                                        &route.provider,
9209                                        &entry.provider_session,
9210                                    )
9211                                })
9212                        })
9213                        .map(|record| record.record_id.clone())
9214                        .collect::<Vec<_>>();
9215                    (matches.len() == 1
9216                        && seen_records.insert(matches[0].clone()))
9217                        .then(|| matches[0].clone())
9218                });
9219                let metadata = entry.metadata;
9220                crate::protocol::NativeSessionCatalogEntry {
9221                    selection_id: metadata.selection_id,
9222                    title: metadata.title,
9223                    modified_at_unix_ms: metadata.modified_at_unix_ms,
9224                    model: metadata.model,
9225                    message_count: metadata.message_count,
9226                    completed_turn_count: metadata.completed_turn_count,
9227                    external_group: entry.external_group,
9228                    record_id,
9229                }
9230            })
9231            .collect()
9232    }
9233
9234    async fn catalog_native_sessions(
9235        &self,
9236        route: NativeSessionCatalogRoute,
9237        limit: u16,
9238    ) -> Result<(
9239        Vec<crate::protocol::NativeSessionCatalogEntry>,
9240        crate::protocol::NativeSessionCatalogSummary,
9241    ), NodeFailure> {
9242        let canonical_root = self.native_session_route_root(&route)?;
9243        let registered_workspace_roots = self.workspace_roots();
9244        let provider = route.provider.clone();
9245        let scope = route.scope;
9246        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9247            failure(
9248                NodeFailureCode::InvalidRequest,
9249                "native session catalog is not configured",
9250            )
9251        })?;
9252        let _permit = Arc::clone(&self.inspection_slots)
9253            .try_acquire_owned()
9254            .map_err(|_| {
9255                failure(
9256                    NodeFailureCode::BackendBusy,
9257                    "native session catalog capacity is busy",
9258                )
9259            })?;
9260        let page = run_native_session_catalog_operation(
9261            catalog,
9262            "native session catalog",
9263            move |catalog| {
9264                catalog.catalog_initial_for_scope(
9265                    &provider,
9266                    scope,
9267                    canonical_root.as_deref().map(Path::new),
9268                    &registered_workspace_roots,
9269                    limit,
9270                )
9271            },
9272        )
9273        .await?;
9274        let summary = crate::protocol::NativeSessionCatalogSummary {
9275            catalog_revision: page.revision,
9276            recent_cutoff_unix_ms: page.cutoff_unix_ms,
9277            recent_total_count: page.recent_total_count,
9278            older_total_count: page.older_total_count,
9279            recent_next_after_selection_id: page.next_after_selection_id,
9280            recent_has_more: page.remaining_count > 0,
9281        };
9282        Ok((self.project_native_session_entries(&route, page.entries), summary))
9283    }
9284
9285    async fn page_native_sessions(
9286        &self,
9287        route: NativeSessionCatalogRoute,
9288        window: crate::protocol::NativeSessionCatalogWindow,
9289        catalog_revision: u64,
9290        recent_cutoff_unix_ms: u64,
9291        after_selection_id: Option<String>,
9292        limit: u16,
9293    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9294        let canonical_root = self.native_session_route_root(&route)?;
9295        let registered_workspace_roots = self.workspace_roots();
9296        let provider = route.provider.clone();
9297        let scope = route.scope;
9298        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9299            failure(
9300                NodeFailureCode::InvalidRequest,
9301                "native session catalog is not configured",
9302            )
9303        })?;
9304        let _permit = Arc::clone(&self.inspection_slots)
9305            .try_acquire_owned()
9306            .map_err(|_| {
9307                failure(
9308                    NodeFailureCode::BackendBusy,
9309                    "native session catalog capacity is busy",
9310                )
9311            })?;
9312        let page = run_native_session_catalog_operation(
9313            catalog,
9314            "native session catalog page",
9315            move |catalog| {
9316                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9317                    &provider,
9318                    scope,
9319                    canonical_root.as_deref().map(Path::new),
9320                    &registered_workspace_roots,
9321                    window,
9322                    catalog_revision,
9323                    recent_cutoff_unix_ms,
9324                    after_selection_id.as_deref(),
9325                    limit,
9326                ))
9327            },
9328        )
9329        .await?;
9330        let page = page.map_err(|error| match error {
9331            NativeSessionCatalogError::StaleCatalog => failure(
9332                NodeFailureCode::StaleNativeSessionCatalog,
9333                "native session catalog revision or cursor is stale",
9334            ),
9335            _ => failure(
9336                NodeFailureCode::BackendOperationFailed,
9337                "native session catalog page failed",
9338            ),
9339        })?;
9340        let entries = self.project_native_session_entries(&route, page.entries);
9341        Ok(crate::protocol::NativeSessionCatalogPage {
9342            window: page.window,
9343            revision: page.revision,
9344            entries,
9345            next_after_selection_id: page.next_after_selection_id,
9346            remaining_count: page.remaining_count,
9347            has_more: page.remaining_count > 0,
9348        })
9349    }
9350
9351    async fn preview_native_session(
9352        &self,
9353        selection: NativeSessionSelection,
9354        message_limit: u16,
9355    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9356        selection
9357            .validate()
9358            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9359        let canonical_root = self.native_session_route_root(&selection.route)?;
9360        let registered_workspace_roots = self.workspace_roots();
9361        let provider = selection.route.provider.clone();
9362        let scope = selection.route.scope;
9363        let catalog_revision = selection.catalog_revision;
9364        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9365        let selection_id = selection.selection_id.clone();
9366        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9367            failure(
9368                NodeFailureCode::InvalidRequest,
9369                "native session preview is not configured",
9370            )
9371        })?;
9372        let _permit = Arc::clone(&self.inspection_slots)
9373            .try_acquire_owned()
9374            .map_err(|_| {
9375                failure(
9376                    NodeFailureCode::BackendBusy,
9377                    "native session preview capacity is busy",
9378                )
9379            })?;
9380        let preview = run_native_session_catalog_operation(
9381            catalog,
9382            "native session preview",
9383            move |catalog| {
9384                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9385                    &provider,
9386                    scope,
9387                    canonical_root.as_deref().map(Path::new),
9388                    &registered_workspace_roots,
9389                    catalog_revision,
9390                    recent_cutoff_unix_ms,
9391                    &selection_id,
9392                    message_limit,
9393                ))
9394            },
9395        )
9396        .await?;
9397        preview
9398            .map(Into::into)
9399            .map_err(native_session_preview_failure)
9400    }
9401
9402    async fn index_native_session(
9403        &self,
9404        selection: NativeSessionSelection,
9405        display_name: String,
9406    ) -> Result<ManagedSessionRecord, NodeFailure> {
9407        selection
9408            .validate()
9409            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9410        if selection.route.scope
9411            != gate4agent_types::NativeSessionCatalogScope::Workspace
9412        {
9413            return Err(failure(
9414                NodeFailureCode::WorkspaceRegistrationRequired,
9415                "register the external project as a workspace before indexing or resuming it",
9416            ));
9417        }
9418        let workspace_id = selection
9419            .route
9420            .workspace_id
9421            .clone()
9422            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9423        let canonical_root = self
9424            .native_session_route_root(&selection.route)?
9425            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9426        let registered_workspace_roots = self.workspace_roots();
9427        let provider = selection.route.provider.clone();
9428        let catalog_revision = selection.catalog_revision;
9429        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9430        let selection_id = selection.selection_id.clone();
9431        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9432            failure(
9433                NodeFailureCode::InvalidRequest,
9434                "native session index is not configured",
9435            )
9436        })?;
9437        let _permit = Arc::clone(&self.inspection_slots)
9438            .try_acquire_owned()
9439            .map_err(|_| {
9440                failure(
9441                    NodeFailureCode::BackendBusy,
9442                    "native session index capacity is busy",
9443                )
9444            })?;
9445        let resolved = run_native_session_catalog_operation(
9446            catalog,
9447            "native session index resolution",
9448            move |catalog| {
9449                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9450                    &provider,
9451                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9452                    Some(Path::new(&canonical_root)),
9453                    &registered_workspace_roots,
9454                    catalog_revision,
9455                    recent_cutoff_unix_ms,
9456                    &selection_id,
9457                ))
9458            },
9459        )
9460        .await?
9461        .map_err(native_session_preview_failure)?;
9462        self.index_provider_session_with_policy(
9463            workspace_id,
9464            selection.route.provider,
9465            resolved.identity,
9466            display_name,
9467            true,
9468        )
9469    }
9470
9471    async fn preview_native_session_by_session_id(
9472        &self,
9473        workspace_id: WorkspaceId,
9474        provider: AgentId,
9475        session_id: String,
9476        message_limit: u16,
9477    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9478        let canonical_root = self.workspace_root(&workspace_id)?;
9479        let registered_workspace_roots = self.workspace_roots();
9480        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9481            failure(
9482                NodeFailureCode::InvalidRequest,
9483                "native session preview is not configured",
9484            )
9485        })?;
9486        let _permit = Arc::clone(&self.inspection_slots)
9487            .try_acquire_owned()
9488            .map_err(|_| {
9489                failure(
9490                    NodeFailureCode::BackendBusy,
9491                    "native session preview capacity is busy",
9492                )
9493            })?;
9494        run_native_session_catalog_operation(
9495            catalog,
9496            "native session preview",
9497            move |catalog| {
9498                catalog.preview_session_id_for_workspace(
9499                    &provider,
9500                    Path::new(&canonical_root),
9501                    &registered_workspace_roots,
9502                    &session_id,
9503                    message_limit,
9504                )
9505            },
9506        )
9507        .await
9508    }
9509
9510    async fn register_workspace(
9511        &self,
9512        workspace_id: WorkspaceId,
9513        root: String,
9514    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9515        validate_workspace_request_root(&root)?;
9516        let canonicalize_workspace_id = workspace_id.clone();
9517        let workspace = tokio::task::spawn_blocking(move || {
9518            WorkspaceConfig::new(canonicalize_workspace_id, root)
9519        })
9520        .await
9521        .map_err(|error| {
9522            failure(
9523                NodeFailureCode::BackendOperationFailed,
9524                &format!("workspace canonicalization task failed: {error}"),
9525            )
9526        })?
9527        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9528        let transaction = self
9529            .state_transaction
9530            .lock()
9531            .unwrap_or_else(|poisoned| poisoned.into_inner());
9532        let mut workspaces = self
9533            .workspaces
9534            .write()
9535            .unwrap_or_else(|poisoned| poisoned.into_inner());
9536        if workspaces.contains_key(&workspace_id) {
9537            return Err(failure(
9538                NodeFailureCode::DuplicateWorkspaceId,
9539                "workspace ID is already registered",
9540            ));
9541        }
9542        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9543            platform::roots_equal(existing_root, workspace.canonical_root())
9544        }) {
9545            return Err(failure(
9546                NodeFailureCode::DuplicateWorkspaceRoot,
9547                &format!("workspace root is already registered as '{existing_id}'"),
9548            ));
9549        }
9550        {
9551            let records = self
9552                .session_records
9553                .lock()
9554                .unwrap_or_else(|poisoned| poisoned.into_inner());
9555            if let Some(record) = records.records.values().find(|record| {
9556                record.workspace_id == workspace_id
9557                    && !record
9558                        .canonical_root
9559                        .as_utf8()
9560                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9561            }) {
9562                return Err(failure(
9563                    NodeFailureCode::SessionRecordConflict,
9564                    &format!(
9565                        "workspace ID is retained by session record '{}' at another root",
9566                        record.record_id,
9567                    ),
9568                ));
9569            }
9570        }
9571        let snapshot = WorkspaceSnapshot {
9572            workspace_id: workspace_id.clone(),
9573            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9574            sessions: Vec::new(),
9575            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9576            managed_worktree_profiles: Some(WorktreeProfileInventory {
9577                profiles: Vec::new(),
9578            }),
9579        };
9580        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9581        drop(workspaces);
9582        let previous_records = {
9583            let mut records = self
9584                .session_records
9585                .lock()
9586                .unwrap_or_else(|poisoned| poisoned.into_inner());
9587            let previous = records.records.clone();
9588            for record in records.records.values_mut().filter(|record| {
9589                record.workspace_id == workspace_id
9590                    && record
9591                        .canonical_root
9592                        .as_utf8()
9593                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9594            }) {
9595                if record.active_session.is_none() {
9596                    record.state = detached_record_state(record);
9597                }
9598                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9599                    record.last_error = None;
9600                }
9601                record.updated_at_unix_ms = unix_time_ms();
9602            }
9603            previous
9604        };
9605        if let Err(error) = self.persist_state_locked() {
9606            self.workspaces
9607                .write()
9608                .unwrap_or_else(|poisoned| poisoned.into_inner())
9609                .remove(&workspace_id);
9610            self.session_records
9611                .lock()
9612                .unwrap_or_else(|poisoned| poisoned.into_inner())
9613                .records = previous_records;
9614            return Err(persistence_failure(error));
9615        }
9616        let restored = self
9617            .session_records
9618            .lock()
9619            .unwrap_or_else(|poisoned| poisoned.into_inner())
9620            .records
9621            .values()
9622            .filter(|record| record.workspace_id == workspace_id)
9623            .cloned()
9624            .collect::<Vec<_>>();
9625        drop(transaction);
9626        self.publish(NodeEvent::WorkspaceAdded {
9627            workspace: snapshot.clone(),
9628        });
9629        for record in restored {
9630            self.publish_record(record);
9631        }
9632        Ok(snapshot)
9633    }
9634
9635    async fn create_standalone_workspace(
9636        &self,
9637        workspace_id: WorkspaceId,
9638        root: String,
9639        initial_branch: Option<String>,
9640    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9641        validate_workspace_request_root(&root)?;
9642        validate_git_revision("initial branch", initial_branch.as_deref())?;
9643        let preflight_workspace_id = workspace_id.clone();
9644        let preflight_root = root.clone();
9645        let canonical_candidate = tokio::task::spawn_blocking(move || {
9646            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9647        })
9648        .await
9649        .map_err(|_| failure(
9650            NodeFailureCode::BackendOperationFailed,
9651            "standalone workspace root preflight task failed",
9652        ))?
9653        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9654        {
9655            let workspaces = self
9656                .workspaces
9657                .read()
9658                .unwrap_or_else(|poisoned| poisoned.into_inner());
9659            if workspaces.contains_key(&workspace_id) {
9660                return Err(failure(
9661                    NodeFailureCode::DuplicateWorkspaceId,
9662                    "workspace ID is already registered",
9663                ));
9664            }
9665            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9666                platform::roots_equal(existing_root, &canonical_candidate)
9667            }) {
9668                return Err(failure(
9669                    NodeFailureCode::DuplicateWorkspaceRoot,
9670                    &format!("workspace root is already registered as '{existing_id}'"),
9671                ));
9672            }
9673        }
9674        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9675            .await
9676            .map_err(standalone_workspace_failure)?;
9677        prepared
9678            .verify_for_registration()
9679            .await
9680            .map_err(standalone_workspace_failure)?;
9681        match self
9682            .register_workspace(workspace_id, prepared.root().to_owned())
9683            .await
9684        {
9685            Ok(workspace) => Ok(workspace),
9686            Err(registration_error) => {
9687                if prepared.compensate_registration_failure().await {
9688                    Err(registration_error)
9689                } else {
9690                    Err(failure(
9691                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9692                        "standalone workspace initialization succeeded but registration failed; recovery is required",
9693                    ))
9694                }
9695            }
9696        }
9697    }
9698
9699    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9700        let bindings = self
9701            .session_bindings
9702            .lock()
9703            .unwrap_or_else(|poisoned| poisoned.into_inner());
9704        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9705            return Err(failure(
9706                NodeFailureCode::WorkspaceBusy,
9707                "workspace retains a bound session; remove every session before unregistering it",
9708            ));
9709        }
9710        drop(bindings);
9711        let transaction = self
9712            .state_transaction
9713            .lock()
9714            .unwrap_or_else(|poisoned| poisoned.into_inner());
9715        let mut workspaces = self
9716            .workspaces
9717            .write()
9718            .unwrap_or_else(|poisoned| poisoned.into_inner());
9719        if !workspaces.contains_key(workspace_id) {
9720            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9721        }
9722        if workspaces.len() == 1 {
9723            return Err(failure(
9724                NodeFailureCode::LastWorkspace,
9725                "node must retain at least one workspace",
9726            ));
9727        }
9728        let removed_root = workspaces
9729            .remove(workspace_id)
9730            .expect("workspace presence was validated");
9731        drop(workspaces);
9732        let previous_records = {
9733            let mut records = self
9734                .session_records
9735                .lock()
9736                .unwrap_or_else(|poisoned| poisoned.into_inner());
9737            let previous = records.records.clone();
9738            for record in records
9739                .records
9740                .values_mut()
9741                .filter(|record| &record.workspace_id == workspace_id)
9742            {
9743                record.active_session = None;
9744                record.state = ManagedSessionState::Unavailable;
9745                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9746                record.updated_at_unix_ms = unix_time_ms();
9747            }
9748            previous
9749        };
9750        if let Err(error) = self.persist_state_locked() {
9751            self.workspaces
9752                .write()
9753                .unwrap_or_else(|poisoned| poisoned.into_inner())
9754                .insert(workspace_id.clone(), removed_root);
9755            self.session_records
9756                .lock()
9757                .unwrap_or_else(|poisoned| poisoned.into_inner())
9758                .records = previous_records;
9759            return Err(persistence_failure(error));
9760        }
9761        let unavailable = self
9762            .session_records
9763            .lock()
9764            .unwrap_or_else(|poisoned| poisoned.into_inner())
9765            .records
9766            .values()
9767            .filter(|record| &record.workspace_id == workspace_id)
9768            .cloned()
9769            .collect::<Vec<_>>();
9770        drop(transaction);
9771        self.publish(NodeEvent::WorkspaceRemoved {
9772            workspace_id: workspace_id.clone(),
9773        });
9774        for record in unavailable {
9775            self.publish_record(record);
9776        }
9777        Ok(())
9778    }
9779
9780    async fn create_worktree(
9781        &self,
9782        source_workspace_id: WorkspaceId,
9783        workspace_id: WorkspaceId,
9784        target_root: String,
9785        branch: String,
9786        base: Option<String>,
9787    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9788        self.require_worktree_service(&source_workspace_id)?;
9789        validate_workspace_request_root(&target_root)?;
9790        validate_git_revision("worktree base", base.as_deref())?;
9791        let source_root = self.workspace_root(&source_workspace_id)?;
9792        {
9793            let workspaces = self
9794                .workspaces
9795                .read()
9796                .unwrap_or_else(|poisoned| poisoned.into_inner());
9797            if workspaces.contains_key(&workspace_id) {
9798                return Err(failure(
9799                    NodeFailureCode::DuplicateWorkspaceId,
9800                    "workspace ID is already registered",
9801                ));
9802            }
9803            if let Some((existing_id, _)) = workspaces
9804                .iter()
9805                .find(|(_, root)| worktree_paths_equal(root, &target_root))
9806            {
9807                return Err(failure(
9808                    NodeFailureCode::DuplicateWorkspaceRoot,
9809                    &format!("workspace root is already registered as '{existing_id}'"),
9810                ));
9811            }
9812        }
9813        let mut worktree = create_git_worktree(
9814            &source_root,
9815            &target_root,
9816            &branch,
9817            base.as_deref(),
9818        )
9819        .await
9820        .map_err(git_worktree_failure)?;
9821        let workspace = match self
9822            .register_workspace(workspace_id, worktree.path.clone())
9823            .await
9824        {
9825            Ok(workspace) => workspace,
9826            Err(registration_error) => {
9827                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9828                    return Err(failure(
9829                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
9830                        "worktree registration failed and compensation requires recovery",
9831                    ));
9832                }
9833                return Err(failure(
9834                    registration_error.code,
9835                    &registration_error.message,
9836                ));
9837            }
9838        };
9839        worktree.workspace_id = Some(workspace.workspace_id.clone());
9840        Ok((protocol_worktree(worktree), workspace))
9841    }
9842
9843    async fn remove_worktree(
9844        &self,
9845        source_workspace_id: WorkspaceId,
9846        target_root: String,
9847    ) -> Result<Option<WorkspaceId>, NodeFailure> {
9848        self.require_worktree_service(&source_workspace_id)?;
9849        validate_workspace_request_root(&target_root)?;
9850        let source_root = self.workspace_root(&source_workspace_id)?;
9851        let target_root = normalize_worktree_removal_target(&target_root)
9852            .map_err(git_worktree_failure)?;
9853        let target = list_git_worktrees(&source_root)
9854            .await
9855            .map_err(git_worktree_failure)?
9856            .into_iter()
9857            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9858            .ok_or_else(|| {
9859                failure(
9860                    NodeFailureCode::WorktreeProtected,
9861                    "refusing to remove a path that is not in Git's worktree listing",
9862                )
9863            })?;
9864        let registered_workspace_id = {
9865            let workspaces = self
9866                .workspaces
9867                .read()
9868                .unwrap_or_else(|poisoned| poisoned.into_inner());
9869            let matched = workspaces
9870                .iter()
9871                .find(|(_, root)| worktree_paths_equal(root, &target.path))
9872                .map(|(workspace_id, _)| workspace_id.clone());
9873            if matched.is_some() && workspaces.len() == 1 {
9874                return Err(failure(
9875                    NodeFailureCode::LastWorkspace,
9876                    "node must retain at least one workspace",
9877                ));
9878            }
9879            matched
9880        };
9881        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9882            let bindings = self
9883                .session_bindings
9884                .lock()
9885                .unwrap_or_else(|poisoned| poisoned.into_inner());
9886            if bindings
9887                .values()
9888                .any(|binding| &binding.workspace_id == workspace_id)
9889            {
9890                return Err(failure(
9891                    NodeFailureCode::WorkspaceBusy,
9892                    "worktree workspace retains a bound session; remove every session first",
9893                ));
9894            }
9895        }
9896        remove_git_worktree(&source_root, &target.path)
9897            .await
9898            .map_err(git_worktree_failure)?;
9899        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9900            self.unregister_workspace(workspace_id)
9901                .map_err(|error| failure(error.code, &error.message))?;
9902        }
9903        Ok(registered_workspace_id)
9904    }
9905
9906    async fn cleanup_managed_worktree(
9907        &self,
9908        lease_id: &ManagedWorktreeLeaseId,
9909        explicit: bool,
9910    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9911        let lease = self.managed_worktrees.lock()
9912            .unwrap_or_else(|poisoned| poisoned.into_inner())
9913            .get(lease_id)
9914            .cloned()
9915            .ok_or_else(|| failure(
9916                NodeFailureCode::UnknownManagedWorktreeLease,
9917                "managed worktree lease does not exist",
9918            ))?;
9919        if lease.state == ManagedWorktreeLeaseState::Removed {
9920            return Ok(lease.snapshot());
9921        }
9922        if lease.has_holders() {
9923            return Err(failure(
9924                NodeFailureCode::ManagedWorktreeBusy,
9925                "managed worktree lease still has a session or durable-record holder",
9926            ));
9927        }
9928        if self
9929            .materializations
9930            .lock()
9931            .unwrap_or_else(|poisoned| poisoned.into_inner())
9932            .values()
9933            .any(|record| record.managed_lease_id() == Some(lease_id))
9934        {
9935            return Err(failure(
9936                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9937                "managed worktree cleanup is blocked by session environment ownership",
9938            ));
9939        }
9940        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9941            let snapshot = {
9942                let mut registry = self.managed_worktrees.lock()
9943                    .unwrap_or_else(|poisoned| poisoned.into_inner());
9944                let current = registry.get_mut(lease_id)
9945                    .expect("managed worktree lease remains present");
9946                current.state = ManagedWorktreeLeaseState::Retained;
9947                current.cleanup_failure = None;
9948                current.updated_at_unix_ms = unix_time_ms();
9949                current.snapshot()
9950            };
9951            if self.persist_state().is_err() {
9952                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9953            }
9954            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9955            return Ok(snapshot);
9956        }
9957        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9958        if profile.revision() != &lease.profile_revision {
9959            self.mark_managed_recovery_required(
9960                lease_id,
9961                ManagedWorktreeCleanupFailure::OwnershipConflict,
9962            );
9963            return Err(failure(
9964                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9965                "managed worktree profile revision changed",
9966            ));
9967        }
9968        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9969            self.mark_managed_recovery_required(
9970                lease_id,
9971                ManagedWorktreeCleanupFailure::OwnershipConflict,
9972            );
9973            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9974        })?;
9975        let source_root = self.workspace_root(&lease.source_workspace_id)?;
9976        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9977            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9978            managed_git_worktree_failure(error)
9979        })?;
9980        let target = listed.iter()
9981            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9982        if let Some(target) = target {
9983            if target.is_main || target.is_bare || !exact_owned_worktree(
9984                &lease,
9985                &target.path,
9986                target.branch.as_deref(),
9987                &target.head,
9988            ) {
9989                self.mark_managed_recovery_required(
9990                    lease_id,
9991                    ManagedWorktreeCleanupFailure::OwnershipConflict,
9992                );
9993                return Err(failure(
9994                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
9995                    "Git worktree identity no longer matches the managed lease",
9996                ));
9997            }
9998            if target.prunable {
9999                self.mark_managed_cleanup_blocked(
10000                    lease_id,
10001                    ManagedWorktreeCleanupFailure::Prunable,
10002                );
10003                return Err(failure(
10004                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
10005                    "managed worktree is prunable and requires recovery",
10006                ));
10007            }
10008            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10009                let failure_kind = match error.kind {
10010                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10011                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10012                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10013                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10014                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10015                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10016                };
10017                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10018                    self.mark_managed_recovery_required(lease_id, failure_kind);
10019                } else {
10020                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10021                }
10022                return Err(managed_git_worktree_failure(error));
10023            }
10024        } else {
10025            match std::fs::symlink_metadata(&lease.target_root) {
10026                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10027                Ok(_) => {
10028                    self.mark_managed_recovery_required(
10029                        lease_id,
10030                        ManagedWorktreeCleanupFailure::OwnershipConflict,
10031                    );
10032                    return Err(failure(
10033                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
10034                        "managed target exists but is not owned by Git",
10035                    ));
10036                }
10037                Err(_) => {
10038                    self.mark_managed_cleanup_blocked(
10039                        lease_id,
10040                        ManagedWorktreeCleanupFailure::Backend,
10041                    );
10042                    return Err(failure(
10043                        NodeFailureCode::BackendOperationFailed,
10044                        "managed target identity could not be inspected",
10045                    ));
10046                }
10047            }
10048        }
10049        let registered = self.workspaces.read()
10050            .unwrap_or_else(|poisoned| poisoned.into_inner())
10051            .contains_key(&lease.workspace_id);
10052        if registered {
10053            self.unregister_workspace(&lease.workspace_id)?;
10054        }
10055        let removed = {
10056            self.managed_worktrees.lock()
10057                .unwrap_or_else(|poisoned| poisoned.into_inner())
10058                .tombstone(lease_id, unix_time_ms())
10059                .expect("managed worktree lease remains present during cleanup")
10060        };
10061        self.persist_state().map_err(persistence_failure)?;
10062        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10063        Ok(removed.snapshot())
10064    }
10065
10066    fn mark_managed_cleanup_blocked(
10067        &self,
10068        lease_id: &ManagedWorktreeLeaseId,
10069        failure_kind: ManagedWorktreeCleanupFailure,
10070    ) {
10071        let snapshot = {
10072            let mut registry = self.managed_worktrees.lock()
10073                .unwrap_or_else(|poisoned| poisoned.into_inner());
10074            let Some(lease) = registry.get_mut(lease_id) else { return };
10075            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10076            lease.cleanup_failure = Some(failure_kind);
10077            lease.updated_at_unix_ms = unix_time_ms();
10078            lease.snapshot()
10079        };
10080        let _ = self.persist_state();
10081        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10082    }
10083
10084    fn require_worktree_service(
10085        &self,
10086        source_workspace_id: &WorkspaceId,
10087    ) -> Result<(), NodeFailure> {
10088        if self.managed_worktrees.lock()
10089            .unwrap_or_else(|poisoned| poisoned.into_inner())
10090            .lease_for_workspace(source_workspace_id)
10091            .is_some()
10092        {
10093            return Err(failure(
10094                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10095                "managed worktree targets are internal-only workspaces",
10096            ));
10097        }
10098        match self
10099            .worktree_service_modes
10100            .get(source_workspace_id)
10101            .copied()
10102            .unwrap_or(WorktreeServiceMode::Manual)
10103        {
10104            WorktreeServiceMode::Manual => Ok(()),
10105            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10106                NodeFailureCode::UnsupportedSpawnCapability,
10107                "Git worktree service mode is unavailable in this node build",
10108            )),
10109        }
10110    }
10111
10112    fn managed_reservation_conflict(
10113        &self,
10114        workspace_id: Option<&WorkspaceId>,
10115        root: Option<&str>,
10116    ) -> bool {
10117        self.managed_worktrees.lock()
10118            .unwrap_or_else(|poisoned| poisoned.into_inner())
10119            .active_records()
10120            .any(|lease| {
10121                workspace_id.is_some_and(|workspace_id| {
10122                    workspace_id == &lease.source_workspace_id
10123                        || workspace_id == &lease.workspace_id
10124                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10125            })
10126    }
10127
10128    fn reject_managed_reservation(
10129        &self,
10130        workspace_id: Option<&WorkspaceId>,
10131        root: Option<&str>,
10132    ) -> Result<(), NodeFailure> {
10133        if self.managed_reservation_conflict(workspace_id, root) {
10134            Err(failure(
10135                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10136                "managed worktree ownership may only be mutated through its lease cleanup",
10137            ))
10138        } else {
10139            Ok(())
10140        }
10141    }
10142
10143    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10144        self.validate_address(address)?;
10145        self.workspace_root(&address.workspace_id)
10146    }
10147
10148    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
10149    /// `require_policy`'s own doc on those two requirements), so they keep
10150    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
10151    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
10152    /// `false` on the `require_policy` call below for them because a PTY
10153    /// resume never needs those facts, not because this method cannot look
10154    /// them up.
10155    ///
10156    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
10157    /// the SAME `binding.runtime_policy` every other requirement reads here
10158    /// (one computed policy, read by every gate). That policy is no longer
10159    /// PTY-terminal-verification-shaped for an ACP session --
10160    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
10161    /// `provider_runtime::policy_for_transport` -- so consulting it for
10162    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
10163    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
10164    /// even though a prompt is an ACP session's only interface). Pipe keeps
10165    /// reading the same PTY-probe-derived value it always has --
10166    /// `policy_for_transport` passes Pipe through unchanged this pass (see
10167    /// its own doc for why).
10168    ///
10169    /// What this method still adds on top, for ACP/Pipe only, is a
10170    /// turn-in-flight PRECONDITION ahead of that policy check: a session
10171    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
10172    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
10173    /// read off the same live `SessionSnapshot` state `require_acp_session`
10174    /// already reads for the ACP control verbs -- `TurnStarted` observed
10175    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
10176    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
10177    /// its fate in vendor-specific behaviour this node cannot observe (one
10178    /// agent may queue it, another drop it, another interleave it), so this
10179    /// refuses it outright rather than gambling on that -- a rule with no
10180    /// PTY analogue, which is why PTY does not take this extra precondition.
10181    fn require_session_runtime_policy(
10182        &self,
10183        address: &SessionAddress,
10184        requirement: ProviderRuntimeRequirement,
10185    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10186        let runtime_policy = {
10187            let bindings = self
10188                .session_bindings
10189                .lock()
10190                .unwrap_or_else(|poisoned| poisoned.into_inner());
10191            let binding = bindings
10192                .get(&address.session.instance_id)
10193                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10194            if binding.workspace_id != address.workspace_id {
10195                return Err(failure(
10196                    NodeFailureCode::SessionWorkspaceMismatch,
10197                    "session belongs to another workspace",
10198                ));
10199            }
10200            if binding.generation != address.session.generation {
10201                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10202            }
10203            binding.runtime_policy
10204        };
10205        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10206            let snapshot = self.internal_session_snapshot(address)?;
10207            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10208                && snapshot.provider.lead_activity != ProviderActivity::Idle
10209            {
10210                return Err(failure(
10211                    NodeFailureCode::TurnInFlight,
10212                    "session already has a turn in flight; wait for it to complete before sending another prompt",
10213                ));
10214            }
10215        }
10216        require_policy(runtime_policy, false, false, requirement)
10217            .map(|()| runtime_policy)
10218            .map_err(|_| failure(
10219                NodeFailureCode::UnsupportedCapability,
10220                "session runtime policy does not admit this semantic operation",
10221            ))
10222    }
10223
10224    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10225        let mut bindings = self
10226            .session_bindings
10227            .lock()
10228            .unwrap_or_else(|poisoned| poisoned.into_inner());
10229        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10230            binding.workspace_id == address.workspace_id
10231                && binding.generation == address.session.generation
10232        }) {
10233            let removed = bindings.remove(&address.session.instance_id);
10234            self.clear_terminal_frame_watermark(address);
10235            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10236            return removed;
10237        }
10238        None
10239    }
10240
10241    fn cleanup_session_owned_materialization(
10242        &self,
10243        address: &SessionAddress,
10244        binding: &SessionBinding,
10245    ) -> Result<(), NodeFailure> {
10246        let Some(id) = binding.materialization_id.as_ref() else {
10247            return Ok(());
10248        };
10249        let owner = self
10250            .materializations
10251            .lock()
10252            .unwrap_or_else(|poisoned| poisoned.into_inner())
10253            .get(id)
10254            .map(|record| record.owner().clone());
10255        match owner {
10256            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10257            Some(MaterializationOwner::Session {
10258                incarnation_id,
10259                instance_id,
10260                generation,
10261            }) if incarnation_id == self.incarnation_id
10262                && instance_id == address.session.instance_id
10263                && generation == address.session.generation =>
10264            {
10265                self.cleanup_materialization(id)
10266            }
10267            Some(MaterializationOwner::Session { .. }) => Err(failure(
10268                NodeFailureCode::BackendOperationFailed,
10269                "session environment ownership requires recovery",
10270            )),
10271        }
10272    }
10273
10274    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10275        let mut watermarks = self
10276            .terminal_frame_watermarks
10277            .lock()
10278            .unwrap_or_else(|poisoned| poisoned.into_inner());
10279        if watermarks
10280            .get(&address.session.instance_id)
10281            .is_some_and(|(current, _)| current == address)
10282        {
10283            watermarks.remove(&address.session.instance_id);
10284        }
10285    }
10286
10287    #[cfg(test)]
10288    fn address_for(
10289        &self,
10290        instance_id: AgentInstanceId,
10291        generation: SessionGeneration,
10292    ) -> Option<SessionAddress> {
10293        let binding = self.session_bindings
10294            .lock()
10295            .unwrap_or_else(|poisoned| poisoned.into_inner())
10296            .get(&instance_id)
10297            .cloned()?;
10298        (binding.generation == generation).then_some(SessionAddress {
10299            workspace_id: binding.workspace_id,
10300            session: SessionKey { instance_id, generation },
10301        })
10302    }
10303
10304    fn arm_resume(
10305        &self,
10306        address: &SessionAddress,
10307        command_id: CommandId,
10308        runtime_policy: ProviderRuntimePolicy,
10309    ) -> Result<(), NodeFailure> {
10310        let mut bindings = self
10311            .session_bindings
10312            .lock()
10313            .unwrap_or_else(|poisoned| poisoned.into_inner());
10314        let binding = bindings
10315            .get_mut(&address.session.instance_id)
10316            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10317        if binding.workspace_id != address.workspace_id {
10318            return Err(failure(
10319                NodeFailureCode::SessionWorkspaceMismatch,
10320                "session belongs to another workspace",
10321            ));
10322        }
10323        if binding.generation != address.session.generation {
10324            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10325        }
10326        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10327        Ok(())
10328    }
10329
10330    fn clear_armed_resume(&self, address: &SessionAddress) {
10331        let mut bindings = self
10332            .session_bindings
10333            .lock()
10334            .unwrap_or_else(|poisoned| poisoned.into_inner());
10335        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10336            if binding.workspace_id == address.workspace_id
10337                && binding.generation == address.session.generation
10338            {
10339                binding.pending_resume = None;
10340            }
10341        }
10342    }
10343
10344    fn publish_control(&self, event: ControlEvent) {
10345        let revokes_harness_mcp = matches!(
10346            &event.event,
10347            ControlEventKind::Exited { .. }
10348                | ControlEventKind::Failed { .. }
10349                | ControlEventKind::Removed
10350        );
10351        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10352            let mut bindings = self
10353                .session_bindings
10354                .lock()
10355                .unwrap_or_else(|poisoned| poisoned.into_inner());
10356            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10357                return;
10358            };
10359            let previous_address = SessionAddress {
10360                workspace_id: binding.workspace_id.clone(),
10361                session: SessionKey {
10362                    instance_id: event.instance_id,
10363                    generation: binding.generation,
10364                },
10365            };
10366            let mut resume_command_rejected = false;
10367            if binding.generation == event.generation {
10368                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10369                    let rejected_resume = matches!(
10370                        &event.event,
10371                        ControlEventKind::CommandRejected { .. }
10372                    ) && event.command_id == Some(pending_command_id);
10373                    let resume_failed = matches!(
10374                        &event.event,
10375                        ControlEventKind::ResumeDenied { .. }
10376                            | ControlEventKind::ResumeFailed { .. }
10377                    );
10378                    if pending_generation == binding.generation
10379                        && (rejected_resume || resume_failed)
10380                    {
10381                        resume_command_rejected = rejected_resume;
10382                        binding.pending_resume = None;
10383                    }
10384                }
10385            } else {
10386                let expected = binding.generation.0.checked_add(1);
10387                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10388                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10389                    generation == binding.generation
10390                })
10391                    || expected != Some(event.generation.0)
10392                    || !authorized
10393                {
10394                    return;
10395                }
10396                let (_, _, runtime_policy) = binding
10397                    .pending_resume
10398                    .expect("authorized resume retains its pending runtime policy");
10399                binding.generation = event.generation;
10400                binding.runtime_policy = runtime_policy;
10401                binding.pending_resume = None;
10402            }
10403            (
10404                SessionAddress {
10405                    workspace_id: binding.workspace_id.clone(),
10406                    session: SessionKey {
10407                        instance_id: event.instance_id,
10408                        generation: event.generation,
10409                    },
10410                },
10411                (previous_address.session.generation != event.generation)
10412                    .then_some(previous_address),
10413                binding.record_id.clone(),
10414                binding.runtime_policy.provider_session_identity,
10415                resume_command_rejected,
10416            )
10417        };
10418        if let Some(previous_address) = previous_address.as_ref() {
10419            self.clear_terminal_frame_watermark(previous_address);
10420        }
10421        if let Some(record_id) = record_id {
10422            self.reconcile_managed_record(
10423                &record_id,
10424                &address,
10425                &event,
10426                identity_admitted,
10427                resume_command_rejected,
10428            );
10429        }
10430        // Every state change a session's process lifecycle can reach gets a
10431        // line here: this is the single chokepoint every `ControlEventKind`
10432        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10433        // came from a fresh spawn or a resume. Per-message provider chatter
10434        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10435        // matched here -- that is exactly the per-frame noise this crate
10436        // family's level discipline forbids at INFO.
10437        match &event.event {
10438            ControlEventKind::Running { process_id } => {
10439                #[cfg(unix)]
10440                if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10441                    proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10442                }
10443                tracing::info!(
10444                    node_id = %self.node_id,
10445                    workspace_id = %address.workspace_id,
10446                    session = ?address.session,
10447                    pid = ?process_id,
10448                    "session process is running",
10449                );
10450            }
10451            ControlEventKind::Exited { exit_code, forced } => {
10452                #[cfg(unix)]
10453                if let Some(proxy) = self.signer_proxy.get().cloned() {
10454                    let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10455                    if proxy.has_session(&key) {
10456                        // Revoke at core off the event path (blocking uplink call).
10457                        std::thread::spawn(move || proxy.release(&key));
10458                    }
10459                }
10460                tracing::info!(
10461                    node_id = %self.node_id,
10462                    workspace_id = %address.workspace_id,
10463                    session = ?address.session,
10464                    exit_code = ?exit_code,
10465                    forced = forced,
10466                    "session process exited",
10467                );
10468            }
10469            ControlEventKind::Failed { message } => {
10470                tracing::warn!(
10471                    node_id = %self.node_id,
10472                    workspace_id = %address.workspace_id,
10473                    session = ?address.session,
10474                    cause = %message,
10475                    "session failed",
10476                );
10477            }
10478            ControlEventKind::CommandRejected { message } => {
10479                tracing::warn!(
10480                    node_id = %self.node_id,
10481                    workspace_id = %address.workspace_id,
10482                    session = ?address.session,
10483                    cause = %message,
10484                    "session command rejected",
10485                );
10486            }
10487            ControlEventKind::Removed => {
10488                tracing::info!(
10489                    node_id = %self.node_id,
10490                    workspace_id = %address.workspace_id,
10491                    session = ?address.session,
10492                    "session removed",
10493                );
10494            }
10495            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10496                tracing::info!(
10497                    node_id = %self.node_id,
10498                    workspace_id = %address.workspace_id,
10499                    session = ?address.session,
10500                    provider_session = ?provider_session,
10501                    "session resume authorized",
10502                );
10503            }
10504            ControlEventKind::Resumed { session: provider_session, process_id } => {
10505                tracing::info!(
10506                    node_id = %self.node_id,
10507                    workspace_id = %address.workspace_id,
10508                    session = ?address.session,
10509                    provider_session = ?provider_session,
10510                    pid = ?process_id,
10511                    "session resumed",
10512                );
10513            }
10514            ControlEventKind::ResumeDenied { reason } => {
10515                tracing::warn!(
10516                    node_id = %self.node_id,
10517                    workspace_id = %address.workspace_id,
10518                    session = ?address.session,
10519                    cause = %reason,
10520                    "session resume denied",
10521                );
10522            }
10523            ControlEventKind::ResumeFailed { message } => {
10524                tracing::warn!(
10525                    node_id = %self.node_id,
10526                    workspace_id = %address.workspace_id,
10527                    session = ?address.session,
10528                    cause = %message,
10529                    "session resume failed",
10530                );
10531            }
10532            _ => {}
10533        }
10534        self.apply_pending_acp_options(&address, &event);
10535        let stream_chunk = agent_stream_chunk(&event);
10536        self.publish(NodeEvent::Control {
10537            address: address.clone(),
10538            event,
10539        });
10540        if let Some(chunk) = stream_chunk {
10541            self.publish_agent_stream_chunk(address.clone(), chunk);
10542        }
10543        if revokes_harness_mcp {
10544            SessionLifecycleHook::on_session_ended(self, &address);
10545        }
10546    }
10547
10548    fn reconcile_managed_record(
10549        &self,
10550        record_id: &SessionRecordId,
10551        address: &SessionAddress,
10552        event: &ControlEvent,
10553        identity_admitted: bool,
10554        resume_command_rejected: bool,
10555    ) {
10556        let _transaction = self
10557            .state_transaction
10558            .lock()
10559            .unwrap_or_else(|poisoned| poisoned.into_inner());
10560        let observed_identity = match &event.event {
10561            ControlEventKind::ProviderEvent {
10562                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10563                ..
10564            } if identity_admitted => Some(identity.clone()),
10565            _ => None,
10566        };
10567        let identity_was_observed = observed_identity.is_some();
10568        let replacement_id = observed_identity
10569            .as_ref()
10570            .and_then(|_| self.allocate_record_id().ok());
10571        let mut upserts = Vec::new();
10572        let mut removals = Vec::new();
10573        let mut rebind = None;
10574        let original_records;
10575        let original_materializations = self
10576            .materializations
10577            .lock()
10578            .unwrap_or_else(|poisoned| poisoned.into_inner())
10579            .clone();
10580        {
10581            let mut records = self
10582                .session_records
10583                .lock()
10584                .unwrap_or_else(|poisoned| poisoned.into_inner());
10585            original_records = records.records.clone();
10586            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10587                return;
10588            };
10589            if let Some(identity) = observed_identity {
10590                let existing_id = records
10591                    .records
10592                    .values()
10593                    .find(|record| {
10594                        record.record_id != *record_id
10595                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10596                                session_registry::same_provider_session(
10597                                    &record.provider,
10598                                    record_identity,
10599                                    &current_snapshot.provider,
10600                                    &identity,
10601                                )
10602                            })
10603                    })
10604                    .map(|record| record.record_id.clone());
10605                if let Some(existing_id) = existing_id {
10606                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10607                        record.mode == current_snapshot.mode
10608                            && record.workspace_id == current_snapshot.workspace_id
10609                            && record.environment_profile == current_snapshot.environment_profile
10610                            && record.bundle == current_snapshot.bundle
10611                            && record.context_id == current_snapshot.context_id
10612                            && record.context == current_snapshot.context
10613                            && record
10614                                .canonical_root
10615                                .as_utf8()
10616                                .zip(current_snapshot.canonical_root.as_utf8())
10617                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
10618                    });
10619                    if !scope_matches {
10620                        if let Some(current) = records.records.get_mut(record_id) {
10621                            current.state = ManagedSessionState::Unavailable;
10622                            current.last_error =
10623                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10624                            current.updated_at_unix_ms = unix_time_ms();
10625                            upserts.push(current.clone());
10626                        }
10627                    } else {
10628                        let existing_is_active = records
10629                            .records
10630                            .get(&existing_id)
10631                            .is_some_and(|record| record.active_session.is_some());
10632                        if existing_is_active {
10633                            if let Some(current) = records.records.get_mut(record_id) {
10634                                current.state = ManagedSessionState::Unavailable;
10635                                current.last_error =
10636                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10637                                current.updated_at_unix_ms = unix_time_ms();
10638                                upserts.push(current.clone());
10639                            }
10640                        } else {
10641                            if current_snapshot.provider_session.is_some() {
10642                                if let Some(current) = records.records.get_mut(record_id) {
10643                                    current.active_session = None;
10644                                    current.state = ManagedSessionState::Dormant;
10645                                    current.updated_at_unix_ms = unix_time_ms();
10646                                    upserts.push(current.clone());
10647                                }
10648                            } else {
10649                                records.records.remove(record_id);
10650                                removals.push(record_id.clone());
10651                            }
10652                            if let Some(existing) = records.records.get_mut(&existing_id) {
10653                                existing.provider_session = Some(identity.clone());
10654                                existing.active_session = Some(address.clone());
10655                                existing.state = ManagedSessionState::Live;
10656                                existing.updated_at_unix_ms = unix_time_ms();
10657                                existing.last_error = None;
10658                                upserts.push(existing.clone());
10659                            }
10660                            rebind = Some(existing_id);
10661                        }
10662                    }
10663                } else if current_snapshot
10664                    .provider_session
10665                    .as_ref()
10666                    .is_some_and(|current| {
10667                        !session_registry::same_provider_session(
10668                            &current_snapshot.provider,
10669                            current,
10670                            &current_snapshot.provider,
10671                            &identity,
10672                        )
10673                    })
10674                {
10675                    if let Some(current) = records.records.get_mut(record_id) {
10676                        current.active_session = None;
10677                        if current.bundle.is_some() || current.context.is_some() {
10678                            let had_bundle = current.bundle.is_some();
10679                            current.provider_session = None;
10680                            current.bundle = None;
10681                            current.context_id = None;
10682                            current.context = None;
10683                            current.state = ManagedSessionState::Unavailable;
10684                            current.last_error = Some(if had_bundle {
10685                                "bundle-unavailable".to_owned()
10686                            } else {
10687                                "context-unavailable".to_owned()
10688                            });
10689                        } else {
10690                            current.state = ManagedSessionState::Dormant;
10691                        }
10692                        current.updated_at_unix_ms = unix_time_ms();
10693                        upserts.push(current.clone());
10694                    }
10695                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10696                        if let Some(current) = records.records.get_mut(record_id) {
10697                            current.state = ManagedSessionState::Unavailable;
10698                            current.last_error =
10699                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10700                            current.updated_at_unix_ms = unix_time_ms();
10701                            upserts.push(current.clone());
10702                        }
10703                    } else if let Some(new_record_id) = replacement_id {
10704                        let now = unix_time_ms();
10705                        let replacement = ManagedSessionRecord {
10706                            record_id: new_record_id.clone(),
10707                            display_name: format!(
10708                                "{} #{}",
10709                                current_snapshot.provider.as_str(),
10710                                records
10711                                    .records
10712                                    .values()
10713                                    .filter(|record| {
10714                                        record.provider == current_snapshot.provider
10715                                    })
10716                                    .count()
10717                                    .saturating_add(1),
10718                            ),
10719                            provider: current_snapshot.provider.clone(),
10720                            mode: current_snapshot.mode,
10721                            state: ManagedSessionState::Live,
10722                            workspace_id: current_snapshot.workspace_id,
10723                            canonical_root: current_snapshot.canonical_root,
10724                            provider_session: Some(identity),
10725                            active_session: Some(address.clone()),
10726                            environment_profile: current_snapshot.environment_profile,
10727                            bundle: current_snapshot.bundle,
10728                            context_id: current_snapshot.context_id,
10729                            context: current_snapshot.context,
10730                            // This is a fresh record_id representing the session under its
10731                            // newly-observed identity; it has not yet produced any clean
10732                            // exit of its own, so it starts with no exported pack (the old
10733                            // record, still addressable under its own record_id, keeps
10734                            // whatever exported_context it already had).
10735                            exported_context: None,
10736                            task_binding: current_snapshot.task_binding,
10737                            created_at_unix_ms: now,
10738                            updated_at_unix_ms: now,
10739                            last_error: None,
10740                        };
10741                        records
10742                            .records
10743                            .insert(new_record_id.clone(), replacement.clone());
10744                        upserts.push(replacement);
10745                        rebind = Some(new_record_id);
10746                    } else if let Some(current) = records.records.get_mut(record_id) {
10747                        current.state = ManagedSessionState::Unavailable;
10748                        current.last_error =
10749                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10750                        upserts.push(current.clone());
10751                    }
10752                } else if let Some(current) = records.records.get_mut(record_id) {
10753                    current.provider_session = Some(identity);
10754                    current.active_session = Some(address.clone());
10755                    current.state = ManagedSessionState::Live;
10756                    current.updated_at_unix_ms = unix_time_ms();
10757                    current.last_error = None;
10758                    upserts.push(current.clone());
10759                }
10760            } else if let Some(current) = records.records.get_mut(record_id) {
10761                if resume_command_rejected {
10762                    current.active_session = None;
10763                    current.state = detached_record_state(current);
10764                    current.updated_at_unix_ms = unix_time_ms();
10765                    current.last_error =
10766                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10767                    upserts.push(current.clone());
10768                } else {
10769                    match &event.event {
10770                    ControlEventKind::Running { .. }
10771                    | ControlEventKind::ResumeAuthorized { .. }
10772                    | ControlEventKind::Resumed { .. } => {
10773                        current.active_session = Some(address.clone());
10774                        current.state = active_record_state(
10775                            identity_admitted,
10776                            current.provider_session.is_some(),
10777                        );
10778                        current.updated_at_unix_ms = unix_time_ms();
10779                        current.last_error = None;
10780                        upserts.push(current.clone());
10781                    }
10782                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10783                        current.active_session = None;
10784                        current.state = detached_record_state(current);
10785                        current.updated_at_unix_ms = unix_time_ms();
10786                        upserts.push(current.clone());
10787                    }
10788                    ControlEventKind::Failed { message }
10789                    | ControlEventKind::ResumeFailed { message } => {
10790                        current.active_session = None;
10791                        current.state = detached_record_state(current);
10792                        current.updated_at_unix_ms = unix_time_ms();
10793                        current.last_error = Some(
10794                            session_registry::sanitized_record_error_summary(message),
10795                        );
10796                        upserts.push(current.clone());
10797                    }
10798                    ControlEventKind::ResumeDenied { reason } => {
10799                        current.active_session = None;
10800                        current.state = detached_record_state(current);
10801                        current.updated_at_unix_ms = unix_time_ms();
10802                        current.last_error = Some(
10803                            session_registry::sanitized_record_error_summary(reason),
10804                        );
10805                        upserts.push(current.clone());
10806                    }
10807                        _ => {}
10808                    }
10809                }
10810            }
10811        }
10812        let materialization_owner_record = identity_was_observed.then(|| {
10813            rebind.clone().unwrap_or_else(|| record_id.clone())
10814        }).and_then(|candidate| {
10815            self.session_records
10816                .lock()
10817                .unwrap_or_else(|poisoned| poisoned.into_inner())
10818                .records
10819                .get(&candidate)
10820                .filter(|record| {
10821                    record.provider_session.is_some()
10822                        && record.active_session.as_ref() == Some(address)
10823                })
10824                .map(|_| candidate)
10825        });
10826        if let Some(owner_record_id) = materialization_owner_record {
10827            let materialization_id = self
10828                .session_bindings
10829                .lock()
10830                .unwrap_or_else(|poisoned| poisoned.into_inner())
10831                .get(&address.session.instance_id)
10832                .and_then(|binding| binding.materialization_id.clone());
10833            if let Some(materialization_id) = materialization_id {
10834                let mut transfer_failed = false;
10835                {
10836                    let mut materializations = self
10837                        .materializations
10838                        .lock()
10839                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10840                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
10841                        match ownership.owner() {
10842                            MaterializationOwner::Session {
10843                                incarnation_id,
10844                                instance_id,
10845                                generation,
10846                            } if *incarnation_id == self.incarnation_id
10847                                && *instance_id == address.session.instance_id
10848                                && *generation == address.session.generation =>
10849                            {
10850                                transfer_failed = ownership
10851                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10852                                    .is_err();
10853                            }
10854                            MaterializationOwner::Record { record_id }
10855                                if record_id == &owner_record_id => {}
10856                            MaterializationOwner::Record { record_id } => {
10857                                let expected = record_id.clone();
10858                                transfer_failed = ownership
10859                                    .transfer_record_owner(
10860                                        &expected,
10861                                        owner_record_id.clone(),
10862                                        unix_time_ms(),
10863                                    )
10864                                    .is_err();
10865                            }
10866                            _ => transfer_failed = true,
10867                        }
10868                        if transfer_failed {
10869                            let _ = ownership.mark_recovery_required(unix_time_ms());
10870                        }
10871                    } else {
10872                        transfer_failed = true;
10873                    }
10874                }
10875                if transfer_failed {
10876                    if let Some(record) = self
10877                        .session_records
10878                        .lock()
10879                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10880                        .records
10881                        .get_mut(&owner_record_id)
10882                    {
10883                        record.state = ManagedSessionState::Unavailable;
10884                        record.last_error = Some("environment-profile-unavailable".to_owned());
10885                        record.updated_at_unix_ms = unix_time_ms();
10886                        upserts.push(record.clone());
10887                    }
10888                }
10889            }
10890        }
10891        if let Some(new_record_id) = rebind.clone() {
10892            if let Some(binding) = self
10893                .session_bindings
10894                .lock()
10895                .unwrap_or_else(|poisoned| poisoned.into_inner())
10896                .get_mut(&address.session.instance_id)
10897            {
10898                binding.record_id = Some(new_record_id);
10899            }
10900        }
10901        if upserts.is_empty() && removals.is_empty() {
10902            return;
10903        }
10904        if self.persist_state_locked().is_err() {
10905            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10906            let mut records = self
10907                .session_records
10908                .lock()
10909                .unwrap_or_else(|poisoned| poisoned.into_inner());
10910            records.records = original_records;
10911            *self
10912                .materializations
10913                .lock()
10914                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10915            if let Some(current) = records.records.get_mut(record_id) {
10916                current.state = ManagedSessionState::Unavailable;
10917                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10918                current.updated_at_unix_ms = unix_time_ms();
10919                upserts = vec![current.clone()];
10920            } else {
10921                upserts.clear();
10922            }
10923            removals.clear();
10924            drop(records);
10925            if let Some(binding) = self
10926                .session_bindings
10927                .lock()
10928                .unwrap_or_else(|poisoned| poisoned.into_inner())
10929                .get_mut(&address.session.instance_id)
10930            {
10931                binding.record_id = Some(record_id.clone());
10932            }
10933            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10934        }
10935        for record in upserts {
10936            self.publish_record(record);
10937        }
10938        for record_id in removals {
10939            self.publish(NodeEvent::SessionRecordRemoved { record_id });
10940        }
10941    }
10942
10943    fn snapshot(&self) -> NodeSnapshot {
10944        let control = self.handle.snapshot();
10945        let bindings = self
10946            .session_bindings
10947            .lock()
10948            .unwrap_or_else(|poisoned| poisoned.into_inner())
10949            .clone();
10950        let workspace_roots = self
10951            .workspaces
10952            .read()
10953            .unwrap_or_else(|poisoned| poisoned.into_inner())
10954            .clone();
10955        let environment_profiles = self
10956            .environment_profiles
10957            .read()
10958            .unwrap_or_else(|poisoned| poisoned.into_inner());
10959        let mut workspaces = workspace_roots
10960            .iter()
10961            .map(|(workspace_id, canonical_root)| {
10962                (
10963                    workspace_id.clone(),
10964                    WorkspaceSnapshot {
10965                        workspace_id: workspace_id.clone(),
10966                        canonical_root: opaque_windows_path(canonical_root.clone()),
10967                        sessions: Vec::new(),
10968                        worktree_service_mode: Some(
10969                            self.worktree_service_modes
10970                                .get(workspace_id)
10971                                .copied()
10972                                .unwrap_or(WorktreeServiceMode::Manual),
10973                        ),
10974                        managed_worktree_profiles: Some(WorktreeProfileInventory {
10975                            profiles: self
10976                                .managed_worktree_profiles
10977                                .get(workspace_id)
10978                                .into_iter()
10979                                .flat_map(BTreeMap::values)
10980                                .map(|profile| ManagedWorktreeProfileSummary {
10981                                    id: profile.profile_id().clone(),
10982                                    revision: profile.revision().clone(),
10983                                    retention: profile.retention(),
10984                                })
10985                                .collect(),
10986                        }),
10987                    },
10988                )
10989            })
10990            .collect::<BTreeMap<_, _>>();
10991        let mut agent_progress = Vec::new();
10992        for session in &control.sessions {
10993            let Some(binding) = bindings.get(&session.instance_id) else {
10994                continue;
10995            };
10996            if binding.generation != session.generation {
10997                continue;
10998            }
10999            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11000                let address = SessionAddress {
11001                    workspace_id: binding.workspace_id.clone(),
11002                    session: SessionKey {
11003                        instance_id: session.instance_id,
11004                        generation: session.generation,
11005                    },
11006                };
11007                if let Some(progress) =
11008                    agent_progress_from_provider_snapshot(address, &session.provider)
11009                {
11010                    agent_progress.push(progress);
11011                }
11012                workspace.sessions.push(session.clone());
11013            }
11014        }
11015        NodeSnapshot {
11016            node_id: self.node_id.clone(),
11017            enabled_providers: self.enabled_providers.clone(),
11018            provider_runtime_statuses: {
11019                let updates = self
11020                    .provider_runtime_status_updates
11021                    .read()
11022                    .unwrap_or_else(|poisoned| poisoned.into_inner());
11023                ProviderRuntimeStatuses::new(
11024                    self.provider_runtime_statuses
11025                        .iter()
11026                        .filter(|status| !updates.contains_key(status.provider()))
11027                        .cloned()
11028                        .chain(updates.values().cloned()),
11029                )
11030                .expect("refreshed provider runtime inventory remains bounded and unique")
11031            },
11032            workspaces: workspaces.into_values().collect(),
11033            session_records: self
11034                .session_records
11035                .lock()
11036                .unwrap_or_else(|poisoned| poisoned.into_inner())
11037                .records
11038                .values()
11039                .cloned()
11040                .collect(),
11041            managed_worktrees: self.managed_worktrees
11042                .lock()
11043                .unwrap_or_else(|poisoned| poisoned.into_inner())
11044                .snapshots(),
11045            launch_inventory: Some(LaunchInventory {
11046                spawn_profiles: Some(
11047                    self.spawn_profiles
11048                        .iter()
11049                        .filter_map(|profile| {
11050                            resolved_spawn_profile_summary(profile, &environment_profiles)
11051                        })
11052                        .collect(),
11053                ),
11054                bundles: Some(
11055                    self.bundle_catalog
11056                        .read()
11057                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11058                        .iter()
11059                        .map(NodeBundle::receipt)
11060                        .collect(),
11061                ),
11062                // Opaque station allowlist ids from empty-default catalog /
11063                // optional startup loader. Never secrets. Dig2 probe stays stub.
11064                network_allowlists: {
11065                    let catalog = self
11066                        .network_allowlist_catalog
11067                        .read()
11068                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11069                    if catalog.is_empty() {
11070                        None
11071                    } else {
11072                        Some(catalog.ids().cloned().collect())
11073                    }
11074                },
11075            }),
11076            agent_progress,
11077        }
11078    }
11079
11080    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11081        assert!(
11082            !matches!(
11083                &event,
11084                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11085            ),
11086            "terminal frame and agent stream events must use their own dedicated live channel",
11087        );
11088        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11089        let sequence = history
11090            .last_sequence
11091            .checked_add(1)
11092            .expect("node event sequence exhausted");
11093        if history.events.len() == NODE_EVENT_HISTORY_MAX {
11094            if let Some(evicted) = history.events.pop_front() {
11095                history.replay_floor_sequence = evicted
11096                    .sequence
11097                    .checked_add(1)
11098                    .expect("node replay floor sequence exhausted");
11099                history.removed_record_providers.remove(&evicted.sequence);
11100            }
11101        }
11102        match &event {
11103            NodeEvent::HarnessMcpReadCall { .. } => {
11104                panic!("transient harness MCP calls must not enter durable event history")
11105            }
11106            NodeEvent::SessionRecordUpserted { record } => {
11107                history
11108                    .record_providers
11109                    .insert(record.record_id.clone(), record.provider.clone());
11110            }
11111            NodeEvent::SessionRecordRemoved { record_id } => {
11112                if let Some(provider) = history.record_providers.remove(record_id) {
11113                    history.removed_record_providers.insert(sequence, provider);
11114                }
11115            }
11116            NodeEvent::ManagedWorktreeUpserted { .. }
11117            | NodeEvent::ManagedWorktreeRemoved { .. }
11118            | NodeEvent::Control { .. }
11119            | NodeEvent::ControllerChanged { .. }
11120            | NodeEvent::SessionRecordHistorySummarized { .. }
11121            | NodeEvent::WorkspaceAdded { .. }
11122            | NodeEvent::WorkspaceRemoved { .. }
11123            | NodeEvent::ResyncRequired { .. }
11124            | NodeEvent::AgentStream { .. }
11125            | NodeEvent::TerminalFrame { .. } => {}
11126        }
11127        let envelope = NodeEventEnvelope { sequence, event };
11128        history.events.push_back(envelope.clone());
11129        history.last_sequence = sequence;
11130        let _ = self.event_tx.send(envelope.clone());
11131        drop(history);
11132        envelope
11133    }
11134
11135    /// Publishes a transient (never durable) `NodeEvent` -- today only
11136    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
11137    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
11138    /// why this must never ride the shared `event_tx` bus). Returns whether
11139    /// the send reached at least one subscriber: `broadcast::Sender::send`
11140    /// fails in exactly one way, no active receiver at that instant, and
11141    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
11142    /// return to fail the pending call immediately rather than leave it to
11143    /// expire silently at `LOCAL_READ_TIMEOUT`/
11144    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
11145    fn publish_transient(&self, event: NodeEvent) -> bool {
11146        assert!(event.requires_harness_mcp_proxy_capability());
11147        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11148        let envelope = NodeEventEnvelope { sequence: 0, event };
11149        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11150            Ok(_) => true,
11151            Err(broadcast::error::SendError(undelivered)) => {
11152                if let Some(NodeEventEnvelope {
11153                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11154                    ..
11155                }) = undelivered.first() {
11156                    // `request.content_type` only, never a per-tool label --
11157                    // this crate does not decode `request.body` and does not
11158                    // hold `hatchery-harness-api` (Nested Control Plane
11159                    // doctrine, Law 3; see this crate's dependency on
11160                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
11161                    tracing::warn!(
11162                        call_id = call_id.as_str(),
11163                        content_type = ?request.content_type,
11164                        cause = "no-subscribers",
11165                        "harness MCP proxy event could not be published",
11166                    );
11167                }
11168                false
11169            }
11170        }
11171    }
11172
11173    fn publish_terminal_frames(&self) {
11174        let snapshot = self.handle.snapshot();
11175        let bindings = self
11176            .session_bindings
11177            .lock()
11178            .unwrap_or_else(|poisoned| poisoned.into_inner());
11179        let candidates = snapshot
11180            .sessions
11181            .iter()
11182            .filter_map(|session| {
11183                let binding = bindings.get(&session.instance_id)?;
11184                if binding.generation != session.generation {
11185                    return None;
11186                }
11187                let frame = session.terminal_frame.clone()?;
11188                crate::session_signals::feed(
11189                    binding.workspace_id.as_str(),
11190                    session.instance_id.0,
11191                    session.agent_id.as_str(),
11192                    &frame.contents,
11193                );
11194                Some((
11195                    SessionAddress {
11196                        workspace_id: binding.workspace_id.clone(),
11197                        session: SessionKey {
11198                            instance_id: session.instance_id,
11199                            generation: session.generation,
11200                        },
11201                    },
11202                    frame,
11203                ))
11204            })
11205            .collect();
11206        drop(bindings);
11207        self.publish_terminal_frame_candidates(candidates);
11208    }
11209
11210    fn publish_terminal_frame_candidates(
11211        &self,
11212        candidates: Vec<(SessionAddress, TerminalFrame)>,
11213    ) {
11214        let mut watermarks = self
11215            .terminal_frame_watermarks
11216            .lock()
11217            .unwrap_or_else(|poisoned| poisoned.into_inner());
11218        let mut events = Vec::with_capacity(candidates.len());
11219        for (address, frame) in candidates {
11220            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11221            let advanced = watermarks
11222                .get(&address.session.instance_id)
11223                .map_or(true, |(current_address, sequence)| {
11224                    current_address != &address || frame.sequence > *sequence
11225                });
11226            if !advanced {
11227                continue;
11228            }
11229            if is_first_frame {
11230                // Do NOT sessionize on first PTY output: a session is a
11231                // provider conversation id, not bytes on the wire. Drafts
11232                // leave the provisional map only when HQ attaches
11233                // (Resize / TerminalBytes). The attach-never-arrived timer
11234                // (PROVISIONAL_SPAWN_GRACE_MS) covers deploy lag; HQ's
11235                // disconnect grace starts only on a real client WS close.
11236                // This is the first PTY output this node has observed for
11237                // this session -- a one-time event per spawn, not per-frame
11238                // chatter (the watermark makes sure of that). A session that
11239                // reaches this line with no controller attached anywhere on
11240                // the node is running unobserved: exactly how processes from
11241                // repeated spawn attempts went orphaned for hours without a
11242                // single line about it.
11243                if self.controller_state().is_none() {
11244                    tracing::warn!(
11245                        node_id = %self.node_id,
11246                        workspace_id = %address.workspace_id,
11247                        session = ?address.session,
11248                        terminal_size = ?frame.size,
11249                        "session producing output with no controller attached",
11250                    );
11251                } else {
11252                    tracing::info!(
11253                        node_id = %self.node_id,
11254                        workspace_id = %address.workspace_id,
11255                        session = ?address.session,
11256                        terminal_size = ?frame.size,
11257                        "session produced its first output",
11258                    );
11259                }
11260            }
11261            watermarks.insert(
11262                address.session.instance_id,
11263                (address.clone(), frame.sequence),
11264            );
11265            if let Some((at, reported)) = self
11266                .pty_input_marks
11267                .lock()
11268                .unwrap_or_else(|poisoned| poisoned.into_inner())
11269                .get_mut(&address.session.instance_id)
11270            {
11271                // First frame after an input: PTY write -> output published.
11272                if !*reported && at.elapsed() < Duration::from_secs(5) {
11273                    *reported = true;
11274                    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");
11275                }
11276            }
11277            events.push(NodeEvent::TerminalFrame { address, frame });
11278        }
11279        drop(watermarks);
11280        if events.is_empty() {
11281            return;
11282        }
11283        let mut history = self
11284            .history
11285            .lock()
11286            .unwrap_or_else(|poisoned| poisoned.into_inner());
11287        let mut envelopes = Vec::with_capacity(events.len());
11288        for event in events {
11289            let sequence = history
11290                .last_sequence
11291                .checked_add(1)
11292                .expect("node event sequence exhausted");
11293            history.last_sequence = sequence;
11294            envelopes.push(NodeEventEnvelope { sequence, event });
11295        }
11296        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11297        drop(history);
11298    }
11299
11300    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11301    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11302    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11303    /// counter every other event kind shares, never a counter of its own --
11304    /// `queue_connection_event`/`queue_connection_event_batch` compare
11305    /// `envelope.sequence` against one `discard_events_through` watermark
11306    /// regardless of which broadcast channel it arrived on, so a
11307    /// per-channel counter would desynchronize that comparison.
11308    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11309        let mut history = self
11310            .history
11311            .lock()
11312            .unwrap_or_else(|poisoned| poisoned.into_inner());
11313        let sequence = history
11314            .last_sequence
11315            .checked_add(1)
11316            .expect("node event sequence exhausted");
11317        history.last_sequence = sequence;
11318        drop(history);
11319        // Debug, not info/warn -- this fires on every single chunk, but it
11320        // is exactly the counter the next drop-measurement pass needs: what
11321        // the node actually emitted (`source_sequence`, `kind`) versus what
11322        // a harness subscriber's own forwarder later logs having received
11323        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11324        // event"), so a gap between the two logs names itself instead of
11325        // needing a new counter added ad hoc.
11326        tracing::debug!(
11327            source_sequence = chunk.source_sequence,
11328            kind = agent_stream_chunk_kind_label(&chunk.kind),
11329            envelope_sequence = sequence,
11330            "publishing agent stream chunk",
11331        );
11332        let envelope = NodeEventEnvelope {
11333            sequence,
11334            event: NodeEvent::AgentStream { address, chunk },
11335        };
11336        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11337    }
11338
11339    fn current_sequence(&self) -> u64 {
11340        self.history
11341            .lock()
11342            .unwrap_or_else(|poisoned| poisoned.into_inner())
11343            .last_sequence
11344    }
11345
11346    fn controller_state(&self) -> Option<ControllerState> {
11347        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11348        let lease = controller.as_ref().copied()?;
11349        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11350        if remaining.is_zero() {
11351            *controller = None;
11352            return None;
11353        }
11354        Some(ControllerState {
11355            connection_id: lease.connection_id,
11356            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11357        })
11358    }
11359
11360    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11361        if role != ClientRole::Operator {
11362            tracing::warn!(
11363                node_id = %self.node_id,
11364                connection_id,
11365                "controller lease rejected: observer connections cannot acquire control",
11366            );
11367            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11368        }
11369        let lease_ms = if lease_ms == 0 {
11370            DEFAULT_CONTROLLER_LEASE_MS
11371        } else {
11372            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11373        };
11374        let now = Instant::now();
11375        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11376        if let Some(active) = controller.as_ref() {
11377            if active.connection_id != connection_id && active.expires_at > now {
11378                tracing::warn!(
11379                    node_id = %self.node_id,
11380                    connection_id,
11381                    holder_connection_id = active.connection_id,
11382                    "controller lease rejected: another operator holds it",
11383                );
11384                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11385            }
11386        }
11387        // A reconnect (this connection already holds the lease, renewing
11388        // before expiry) happens on a periodic cadence; logging it at INFO
11389        // every renewal would be exactly the heartbeat chatter this crate
11390        // family's level discipline forbids. A genuine attach -- the
11391        // controller identity actually changing -- is a state change and
11392        // gets INFO; a same-connection renewal gets DEBUG.
11393        let is_new_attach = match controller.as_ref() {
11394            Some(active) => active.connection_id != connection_id,
11395            None => true,
11396        };
11397        *controller = Some(ControllerLease {
11398            connection_id,
11399            expires_at: now + Duration::from_millis(lease_ms),
11400        });
11401        drop(controller);
11402        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11403        if is_new_attach {
11404            tracing::info!(
11405                node_id = %self.node_id,
11406                connection_id,
11407                lease_ms,
11408                "controller lease acquired",
11409            );
11410        } else {
11411            tracing::debug!(
11412                node_id = %self.node_id,
11413                connection_id,
11414                lease_ms,
11415                "controller lease renewed",
11416            );
11417        }
11418        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11419        Ok(state)
11420    }
11421
11422    fn release_controller(&self, connection_id: u64) -> bool {
11423        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11424        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11425        if released {
11426            *controller = None;
11427        }
11428        drop(controller);
11429        if released {
11430            tracing::info!(
11431                node_id = %self.node_id,
11432                connection_id,
11433                "controller lease released",
11434            );
11435            self.publish(NodeEvent::ControllerChanged { controller: None });
11436        }
11437        released
11438    }
11439
11440    /// Mark a fresh Spawn as provisional and arm the attach-never-arrived
11441    /// reaper (PROVISIONAL_SPAWN_GRACE_MS from spawn). Removed from this map
11442    /// only when HQ attaches (Resize / TerminalBytes) — that is *not* a
11443    /// provider session; it only cancels the orphan timer. Disconnect/reload
11444    /// draft kills are owned by HQ and arm only on a real client WS close.
11445    /// ACP path of launch options: once the agent advertises its config options, write the
11446    /// pending model / effort through session/set_config_option (session/set_model when the
11447    /// agent has no model option). Gaps are logged, never forced.
11448    fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11449        let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11450            return;
11451        };
11452        let id = address.session.instance_id;
11453        let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11454            return;
11455        };
11456        let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11457            .iter()
11458            .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11459            .collect();
11460        let (writes, set_model, effort_gap) =
11461            backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11462        if set_model {
11463            if let Some(model_id) = model.clone() {
11464                if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11465                    tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11466                }
11467            }
11468        }
11469        for (option_id, value_json) in writes {
11470            if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11471                tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11472            }
11473        }
11474        if effort_gap {
11475            tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11476        }
11477        tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11478    }
11479
11480    fn mark_spawn_provisional(&self, address: SessionAddress) {
11481        let id = address.session.instance_id;
11482        self.provisional_spawns
11483            .lock()
11484            .unwrap_or_else(|poisoned| poisoned.into_inner())
11485            .insert(id, (address, Instant::now()));
11486        tracing::info!(
11487            node_id = %self.node_id,
11488            instance_id = id.0,
11489            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11490            "draft spawn; waiting for HQ view",
11491        );
11492    }
11493
11494    fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11495        let removed = self
11496            .provisional_spawns
11497            .lock()
11498            .unwrap_or_else(|poisoned| poisoned.into_inner())
11499            .remove(&instance_id);
11500        if removed.is_some() {
11501            tracing::info!(
11502                node_id = %self.node_id,
11503                instance_id = instance_id.0,
11504                "draft spawn claimed by HQ view",
11505            );
11506        }
11507    }
11508
11509    fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11510        let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11511        let mut map = self
11512            .provisional_spawns
11513            .lock()
11514            .unwrap_or_else(|poisoned| poisoned.into_inner());
11515        let due: Vec<AgentInstanceId> = map
11516            .iter()
11517            .filter(|(_, (_, at))| at.elapsed() >= grace)
11518            .map(|(id, _)| *id)
11519            .collect();
11520        due.into_iter()
11521            .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11522            .collect()
11523    }
11524
11525    async fn reap_provisional_spawn(&self, address: SessionAddress) {
11526        let instance_id = address.session.instance_id;
11527        tracing::warn!(
11528            node_id = %self.node_id,
11529            instance_id = instance_id.0,
11530            workspace = %address.workspace_id,
11531            reason = "attach_never_arrived",
11532            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11533            "draft spawn never viewed; stopping PTY",
11534        );
11535        let _ = self.dispatch(ControlCommand::Stop {
11536            instance_id,
11537            force: true,
11538        });
11539        let _ = self.remove_session(&address).await;
11540    }
11541
11542    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11543        if role != ClientRole::Operator {
11544            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11545        }
11546        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11547        let now = Instant::now();
11548        match controller.as_ref().copied() {
11549            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11550            Some(active) if active.expires_at <= now => {
11551                *controller = None;
11552                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11553            }
11554            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11555        }
11556    }
11557
11558    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11559        if !self
11560            .workspaces
11561            .read()
11562            .unwrap_or_else(|poisoned| poisoned.into_inner())
11563            .contains_key(&address.workspace_id)
11564        {
11565            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11566        }
11567        let binding = self
11568            .session_bindings
11569            .lock()
11570            .unwrap_or_else(|poisoned| poisoned.into_inner())
11571            .get(&address.session.instance_id)
11572            .cloned()
11573            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11574        if binding.workspace_id != address.workspace_id {
11575            return Err(failure(
11576                NodeFailureCode::SessionWorkspaceMismatch,
11577                "session belongs to another workspace",
11578            ));
11579        }
11580        if binding.generation != address.session.generation {
11581            return Err(failure(
11582                NodeFailureCode::StaleGeneration,
11583                &format!(
11584                    "stale session generation: expected {}, received {}",
11585                    binding.generation.0,
11586                    address.session.generation.0,
11587                ),
11588            ));
11589        }
11590        let snapshot = self.handle.snapshot();
11591        let Some(current) = snapshot
11592            .sessions
11593            .iter()
11594            .find(|item| item.instance_id == address.session.instance_id)
11595        else {
11596            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11597        };
11598        if current.generation != address.session.generation {
11599            return Err(failure(
11600                NodeFailureCode::StaleGeneration,
11601                &format!(
11602                    "stale session generation: expected {}, received {}",
11603                    current.generation.0,
11604                    address.session.generation.0,
11605                ),
11606            ));
11607        }
11608        Ok(current.agent_id.clone())
11609    }
11610
11611    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11612        self.dispatch_envelope(self.prepare_command(command))
11613    }
11614
11615    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11616        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11617        CommandEnvelope {
11618            id: CommandId(id),
11619            command,
11620        }
11621    }
11622
11623    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11624        let command_id = envelope.id;
11625        self.handle
11626            .dispatch(envelope)
11627            .map_err(|error| match error {
11628                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11629                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11630            })?;
11631        Ok(command_id)
11632    }
11633
11634    /// Records the kernel's own reason for asynchronously refusing
11635    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
11636    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
11637    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
11638    /// first, and if the map is still at capacity after that, the single
11639    /// oldest surviving entry is evicted -- `CommandId` is monotonically
11640    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
11641    /// is always the oldest.
11642    fn record_rejected_command(
11643        &self,
11644        command_id: CommandId,
11645        message: String,
11646        unsupported_transport: bool,
11647    ) {
11648        let now = Instant::now();
11649        let mut reasons = self.rejected_command_reasons.lock()
11650            .unwrap_or_else(|poisoned| poisoned.into_inner());
11651        reasons.retain(|_, reason| reason.expires_at > now);
11652        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11653            if let Some(&oldest) = reasons.keys().next() {
11654                reasons.remove(&oldest);
11655            }
11656        }
11657        reasons.insert(
11658            command_id,
11659            RejectedCommandReason {
11660                message,
11661                unsupported_transport,
11662                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11663            },
11664        );
11665    }
11666
11667    /// Removes and returns the kernel's reason for refusing `command_id`,
11668    /// if the drive loop has recorded one and it has not expired -- see
11669    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
11670    /// wait loop calls this instead of blindly polling out its whole
11671    /// deadline when the command it is waiting on turns out to have been
11672    /// refused rather than merely slow.
11673    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11674        let now = Instant::now();
11675        let mut reasons = self.rejected_command_reasons.lock()
11676            .unwrap_or_else(|poisoned| poisoned.into_inner());
11677        match reasons.remove(&command_id) {
11678            Some(reason) if reason.expires_at > now => Some(reason),
11679            _ => None,
11680        }
11681    }
11682
11683    async fn dispatch_bounded(
11684        &self,
11685        command: ControlCommand,
11686        timeout_duration: Duration,
11687    ) -> Result<CommandId, NodeFailure> {
11688        let deadline = Instant::now() + timeout_duration;
11689        loop {
11690            match self.dispatch(command.clone()) {
11691                Ok(command_id) => return Ok(command_id),
11692                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11693                    sleep(Duration::from_millis(2)).await;
11694                }
11695                Err(error) => return Err(error),
11696            }
11697        }
11698    }
11699
11700    fn internal_session_snapshot(
11701        &self,
11702        address: &SessionAddress,
11703    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11704        self.validate_address(address)?;
11705        self.handle
11706            .snapshot()
11707            .sessions
11708            .clone()
11709            .into_iter()
11710            .find(|session| {
11711                session.instance_id == address.session.instance_id
11712                    && session.generation == address.session.generation
11713            })
11714            .ok_or_else(|| {
11715                failure(
11716                    NodeFailureCode::UnknownSession,
11717                    "session snapshot is unavailable",
11718                )
11719            })
11720    }
11721
11722    /// Confirms `address` names a live session spawned over the ACP
11723    /// transport and returns its current snapshot -- the gate every
11724    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
11725    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
11726    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
11727    /// the session's own recorded `TransportKind` does, and that lives on
11728    /// the live snapshot, not the binding.
11729    fn require_acp_session(
11730        &self,
11731        address: &SessionAddress,
11732    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11733        let snapshot = self.internal_session_snapshot(address)?;
11734        if snapshot.transport != TransportKind::Acp {
11735            return Err(failure(
11736                NodeFailureCode::UnsupportedTransport,
11737                "this verb requires a session spawned over the ACP transport",
11738            ));
11739        }
11740        Ok(snapshot)
11741    }
11742
11743    async fn dispatch_history_bounded(
11744        &self,
11745        address: &SessionAddress,
11746        command: ControlCommand,
11747        operation: HistoryOperation,
11748    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11749        self.validate_address(address)?;
11750        let started_after = self.current_sequence();
11751        let command_id = self
11752            .dispatch_bounded(
11753                command,
11754                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11755            )
11756            .await?;
11757        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11758        let mut scan_after = started_after;
11759        let mut accepted_after = None;
11760        loop {
11761            let events = self
11762                .history
11763                .lock()
11764                .unwrap_or_else(|poisoned| poisoned.into_inner())
11765                .events
11766                .iter()
11767                .filter(|envelope| envelope.sequence > scan_after)
11768                .cloned()
11769                .collect::<Vec<_>>();
11770            for envelope in events {
11771                scan_after = scan_after.max(envelope.sequence);
11772                let NodeEvent::Control {
11773                    address: event_address,
11774                    event,
11775                } = envelope.event
11776                else {
11777                    continue;
11778                };
11779                if event_address != *address {
11780                    continue;
11781                }
11782                if event.command_id == Some(command_id) {
11783                    match &event.event {
11784                        ControlEventKind::CommandRejected { .. } => {
11785                            return Err(failure(
11786                                NodeFailureCode::InvalidRequest,
11787                                "history request was rejected",
11788                            ));
11789                        }
11790                        ControlEventKind::HistoryRequested {
11791                            operation: requested,
11792                            ..
11793                        } if requested == &operation => {
11794                            accepted_after = Some(envelope.sequence);
11795                            continue;
11796                        }
11797                        _ => {}
11798                    }
11799                }
11800                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11801                    match (&operation, &event.event) {
11802                        (
11803                            HistoryOperation::Discover { .. },
11804                            ControlEventKind::HistoryDiscovered { .. },
11805                        )
11806                        | (
11807                            HistoryOperation::Load { .. },
11808                            ControlEventKind::HistoryLoaded { .. },
11809                        ) => return self.internal_session_snapshot(address),
11810                        (_, ControlEventKind::HistoryFailed { .. }) => {
11811                            return Err(failure(
11812                                NodeFailureCode::BackendOperationFailed,
11813                                "history operation failed",
11814                            ));
11815                        }
11816                        _ => {}
11817                    }
11818                }
11819            }
11820            if Instant::now() >= deadline {
11821                return Err(failure(
11822                    NodeFailureCode::BackendBusy,
11823                    if accepted_after.is_some() {
11824                        "history operation did not settle before the bounded deadline"
11825                    } else {
11826                        "history operation was not accepted before the bounded deadline"
11827                    },
11828                ));
11829            }
11830            sleep(Duration::from_millis(2)).await;
11831        }
11832    }
11833
11834    async fn discover_history(
11835        &self,
11836        address: &SessionAddress,
11837        limit: u16,
11838    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11839        let query = HistoryQuery {
11840            working_directory: Some(self.workspace_root(&address.workspace_id)?),
11841            limit,
11842        };
11843        query
11844            .validate()
11845            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11846        let snapshot = self
11847            .dispatch_history_bounded(
11848                address,
11849                ControlCommand::DiscoverHistory {
11850                    instance_id: address.session.instance_id,
11851                    query: query.clone(),
11852                },
11853                HistoryOperation::Discover { query },
11854            )
11855            .await?;
11856        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11857            return Err(failure(
11858                NodeFailureCode::BackendOperationFailed,
11859                "history discovery settled into an invalid state",
11860            ));
11861        }
11862        Ok(snapshot.history.candidates)
11863    }
11864
11865    async fn load_history(
11866        &self,
11867        address: &SessionAddress,
11868        candidate_id: String,
11869    ) -> Result<HistorySessionRecord, NodeFailure> {
11870        validate_candidate_id(&candidate_id)
11871            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11872        let snapshot = self
11873            .dispatch_history_bounded(
11874                address,
11875                ControlCommand::LoadHistory {
11876                    instance_id: address.session.instance_id,
11877                    candidate_id: candidate_id.clone(),
11878                },
11879                HistoryOperation::Load {
11880                    candidate_id: candidate_id.clone(),
11881                },
11882            )
11883            .await?;
11884        if snapshot.history.pending.is_some()
11885            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11886        {
11887            return Err(failure(
11888                NodeFailureCode::BackendOperationFailed,
11889                "history load settled into an invalid state",
11890            ));
11891        }
11892        snapshot.history.loaded.ok_or_else(|| {
11893            failure(
11894                NodeFailureCode::BackendOperationFailed,
11895                "history load produced no bounded session",
11896            )
11897        })
11898    }
11899
11900    /// `allow_clean_detachment`: `false` for the on-demand
11901    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
11902    /// managed record must stay untouched, still live, for the whole call);
11903    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
11904    /// reacts to the very clean-exit event that detaches the record out from
11905    /// under this same call — see `session_record_export_target_matches`.
11906    async fn export_context_pack_for_session_record(
11907        &self,
11908        record_id: &SessionRecordId,
11909        session: &SessionAddress,
11910        allow_clean_detachment: bool,
11911    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11912        // C2 replaces a relayed failure's prose with a canned per-code
11913        // string (`C2NodeFailure`'s own `From`), so this path's dozen
11914        // distinct refusals are indistinguishable to anyone downstream --
11915        // and a refusal here expires a continuation, which keeps no field
11916        // for why. The prose stays on the node, where it was written.
11917        let outcome = self
11918            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11919            .await;
11920        if let Err(failure) = &outcome {
11921            tracing::warn!(
11922                record = %record_id,
11923                code = ?failure.code,
11924                reason = %failure.message,
11925                "session-record context-pack export refused",
11926            );
11927        }
11928        outcome
11929    }
11930
11931    async fn export_context_pack_for_session_record_inner(
11932        &self,
11933        record_id: &SessionRecordId,
11934        session: &SessionAddress,
11935        allow_clean_detachment: bool,
11936    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11937        // Several steps here refuse with the same BackendOperationFailed
11938        // code and no prose of their own, so which step failed is
11939        // otherwise unrecoverable -- and a refusal here expires a
11940        // continuation.
11941        let step = |name: &'static str| move |failure: NodeFailure| {
11942            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11943            failure
11944        };
11945        let record = self.record(record_id).map_err(step("record-lookup"))?;
11946        // `provider_session` is `Some` only for a provider whose resolved
11947        // runtime policy admits live identity verification (raw PTY plus a
11948        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
11949        // The PTY-sidecar-observation shape (no fleet provider today, but
11950        // capability-keyed rather than vendor-locked) hardcodes
11951        // `provider_session_identity: false` in `provider_runtime::evaluate`,
11952        // so its records go straight to `Live` with `provider_session: None`
11953        // and stay that way for the record's entire life -- that is this
11954        // shape's permanent resting state, not a race with observation, and
11955        // `discover_history`/`load_history` below do not need an identity to
11956        // do their job for it. `IdentityPending` is the actual race (an
11957        // identity-admitted provider whose observation has not landed yet):
11958        // it keeps refusing here rather than falling back, because that
11959        // provider's own history the fallback would otherwise trust is not
11960        // guaranteed to be exact without the identity it is still waiting on.
11961        let identity = match record.provider_session.clone() {
11962            Some(identity) => {
11963                if identity.key != ProviderSessionKey::SessionId {
11964                    return Err(step("provider-identity-key")(failure(
11965                        NodeFailureCode::BackendOperationFailed,
11966                        "managed session provider identity cannot bind history exactly",
11967                    )));
11968                }
11969                Some(identity)
11970            }
11971            None if record.state == ManagedSessionState::IdentityPending => {
11972                return Err(step("provider-session-identity")(failure(
11973                    NodeFailureCode::BackendOperationFailed,
11974                    "managed session has not yet observed its provider session identity",
11975                )));
11976            }
11977            None => None,
11978        };
11979        self.revalidate_session_record_context_export(
11980            &record,
11981            identity.as_ref(),
11982            session,
11983            allow_clean_detachment,
11984        ).map_err(step("revalidate-before-discovery"))?;
11985        let candidates = self
11986            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11987            .await
11988            .map_err(step("discover-history"))?;
11989        // With no verified identity to filter by, the discovered set must
11990        // still resolve to exactly one candidate: the same ambiguity refusal
11991        // an identity filter would otherwise produce is the only safety
11992        // property left available for this shape.
11993        let candidate_id = select_history_candidate(
11994            &candidates,
11995            identity.as_ref().map(|identity| identity.id.as_str()),
11996        ).map_err(step("unique-candidate"))?;
11997        let loaded = self.load_history(session, candidate_id.clone()).await
11998            .map_err(step("load-history"))?;
11999        if let Some(identity) = identity.as_ref() {
12000            if loaded.session_id != identity.id {
12001                return Err(failure(
12002                    NodeFailureCode::SessionRecordConflict,
12003                    "loaded history identity does not match the managed session record",
12004                ));
12005            }
12006        }
12007        self.revalidate_session_record_context_export(
12008            &record,
12009            identity.as_ref(),
12010            session,
12011            allow_clean_detachment,
12012        )?;
12013        self.revalidate_loaded_history(session, &candidate_id, &loaded)
12014            .map_err(step("revalidate-loaded-history"))?;
12015        let pack = self.materialize_context_pack(session).await
12016            .map_err(step("materialize-context-pack"))?;
12017        self.commit_context_pack_for_session_record(
12018            &record,
12019            identity.as_ref(),
12020            session,
12021            &candidate_id,
12022            &loaded,
12023            pack,
12024            allow_clean_detachment,
12025        )
12026    }
12027
12028    async fn export_context_pack(
12029        &self,
12030        address: &SessionAddress,
12031    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12032        let pack = self.materialize_context_pack(address).await?;
12033        self.commit_context_pack(pack)
12034    }
12035
12036    async fn materialize_context_pack(
12037        &self,
12038        address: &SessionAddress,
12039    ) -> Result<NodeContextPack, NodeFailure> {
12040        let snapshot = self.internal_session_snapshot(address)?;
12041        if !context_pack_source_status_is_usable(&snapshot.status) {
12042            return Err(failure(
12043                NodeFailureCode::InvalidRequest,
12044                "context export requires a running or successfully exited source session",
12045            ));
12046        }
12047        if snapshot.history.pending.is_some()
12048            || snapshot.history.loaded_candidate_id.is_none()
12049        {
12050            return Err(failure(
12051                NodeFailureCode::InvalidRequest,
12052                "context export requires settled loaded history",
12053            ));
12054        }
12055        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12056            failure(
12057                NodeFailureCode::InvalidRequest,
12058                "context export requires settled loaded history",
12059            )
12060        })?;
12061        let source_provider = snapshot.agent_id.clone();
12062        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12063        let repository = self
12064            .context_pack_repository(&address.workspace_id)
12065            .await?;
12066        let current = self.internal_session_snapshot(address)?;
12067        if !context_pack_source_status_is_usable(&current.status)
12068            || current.agent_id != source_provider
12069            || current.history.pending.is_some()
12070            || current.history.loaded_candidate_id != loaded_candidate_id
12071            || current.history.loaded.as_ref() != Some(&loaded)
12072        {
12073            return Err(failure(
12074                NodeFailureCode::ContextPackMaterializationFailed,
12075                "context source changed during bounded repository capture",
12076            ));
12077        }
12078        NodeContextPack::export_with_repository(
12079            ContextPackLineageReceipt {
12080                source_node_id: self.node_id.clone(),
12081                source_session: address.clone(),
12082                source_provider,
12083            },
12084            &loaded,
12085            Some(repository),
12086        )
12087        .map_err(|_| {
12088            failure(
12089                NodeFailureCode::ContextPackMaterializationFailed,
12090                "bounded context pack export failed",
12091            )
12092        })
12093    }
12094
12095    /// Write-through to the durable [`ContextPackStore`], if one is
12096    /// configured (`state_path` is `Some`). A node with no durable state
12097    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
12098    /// open at all and this is a no-op, matching `persist_state`'s own
12099    /// "durability optional, degrade gracefully when unconfigured" idiom.
12100    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12101        let mut store = self
12102            .context_pack_store
12103            .lock()
12104            .unwrap_or_else(|poisoned| poisoned.into_inner());
12105        let Some(store) = store.as_mut() else {
12106            return Ok(());
12107        };
12108        store.commit(pack).map_err(|_| {
12109            failure(
12110                NodeFailureCode::ContextPackMaterializationFailed,
12111                "durable context pack commit failed",
12112            )
12113        })
12114    }
12115
12116    fn commit_context_pack(
12117        &self,
12118        pack: NodeContextPack,
12119    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12120        self.commit_context_pack_durably(&pack)?;
12121        self.context_catalog
12122            .write()
12123            .unwrap_or_else(|poisoned| poisoned.into_inner())
12124            .insert(pack)
12125            .map_err(|_| {
12126                failure(
12127                    NodeFailureCode::ContextPackMaterializationFailed,
12128                    "context pack catalog is full",
12129                )
12130            })
12131    }
12132
12133    fn commit_context_pack_for_session_record(
12134        &self,
12135        expected: &ManagedSessionRecord,
12136        identity: Option<&ProviderSessionIdentity>,
12137        session: &SessionAddress,
12138        candidate_id: &str,
12139        loaded: &HistorySessionRecord,
12140        pack: NodeContextPack,
12141        allow_clean_detachment: bool,
12142    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12143        let current_root = self.workspace_root(&expected.workspace_id)?;
12144        let _transaction = self
12145            .state_transaction
12146            .lock()
12147            .unwrap_or_else(|poisoned| poisoned.into_inner());
12148        let records = self
12149            .session_records
12150            .lock()
12151            .unwrap_or_else(|poisoned| poisoned.into_inner());
12152        let current = records.records.get(&expected.record_id).ok_or_else(|| {
12153            failure(
12154                NodeFailureCode::SessionRecordConflict,
12155                "managed session record disappeared during context export",
12156            )
12157        })?;
12158        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12159            return Err(failure(
12160                NodeFailureCode::SessionRecordConflict,
12161                "managed session record changed during context export",
12162            ));
12163        }
12164        let bindings = self
12165            .session_bindings
12166            .lock()
12167            .unwrap_or_else(|poisoned| poisoned.into_inner());
12168        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12169            failure(
12170                NodeFailureCode::SessionRecordConflict,
12171                "managed session runtime binding disappeared during context export",
12172            )
12173        })?;
12174        let runtime = self
12175            .handle
12176            .snapshot()
12177            .sessions
12178            .iter()
12179            .find(|snapshot| {
12180                snapshot.instance_id == session.session.instance_id
12181                    && snapshot.generation == session.session.generation
12182            })
12183            .cloned()
12184            .ok_or_else(|| {
12185                failure(
12186                    NodeFailureCode::SessionRecordConflict,
12187                    "managed session runtime disappeared during context export",
12188                )
12189            })?;
12190        let receipt = pack.receipt();
12191        if !session_record_context_export_binding_is_exact(
12192            expected,
12193            current,
12194            identity,
12195            session,
12196            binding,
12197            &runtime.agent_id,
12198            &current_root,
12199            allow_clean_detachment,
12200        ) || !session_record_context_export_source_is_usable(
12201            current,
12202            &runtime.status,
12203            allow_clean_detachment,
12204        ) || runtime.history.pending.is_some()
12205            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12206            || runtime.history.loaded.as_ref() != Some(loaded)
12207            || receipt.lineage.source_node_id != self.node_id
12208            || receipt.lineage.source_session != *session
12209            || receipt.lineage.source_provider != current.provider
12210            || receipt.source_message_count != loaded.message_count
12211        {
12212            return Err(failure(
12213                NodeFailureCode::SessionRecordConflict,
12214                "managed session source changed before context catalog commit",
12215            ));
12216        }
12217        self.commit_context_pack_durably(&pack)?;
12218        self.context_catalog
12219            .write()
12220            .unwrap_or_else(|poisoned| poisoned.into_inner())
12221            .insert(pack)
12222            .map_err(|_| {
12223                failure(
12224                    NodeFailureCode::ContextPackMaterializationFailed,
12225                    "context pack catalog is full",
12226                )
12227            })
12228    }
12229
12230    async fn context_pack_repository(
12231        &self,
12232        workspace_id: &WorkspaceId,
12233    ) -> Result<ContextPackRepository, NodeFailure> {
12234        let first = self
12235            .context_pack_repository_observation(workspace_id)
12236            .await?;
12237        let second = self
12238            .context_pack_repository_observation(workspace_id)
12239            .await?;
12240        stable_context_pack_repository(first, second)
12241    }
12242
12243    async fn context_pack_repository_observation(
12244        &self,
12245        workspace_id: &WorkspaceId,
12246    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12247        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12248        let head = if git.is_repository {
12249            let root = self.workspace_root(workspace_id)?;
12250            match resolve_base_commit_with_timeout(
12251                &root,
12252                "HEAD",
12253                GIT_COMMAND_TIMEOUT_MS,
12254            )
12255            .await
12256            {
12257                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12258                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12259                    ContextPackRepositoryHead::Unborn
12260                }
12261                Err(_) => {
12262                    return Err(failure(
12263                        NodeFailureCode::ContextPackMaterializationFailed,
12264                        "context pack HEAD observation failed",
12265                    ));
12266                }
12267            }
12268        } else {
12269            ContextPackRepositoryHead::NotRepository
12270        };
12271        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12272        for selected_path in CONTEXT_PACK_SELECTED_FILES {
12273            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12274                failure(
12275                    NodeFailureCode::InvalidRepositoryPath,
12276                    "context pack selected file path is invalid",
12277                )
12278            })?;
12279            match self.read_workspace_file(workspace_id.clone(), path).await {
12280                Ok(WorkspaceFileRead {
12281                    content: WorkspaceFileContent::Utf8 { text, byte_len },
12282                    ..
12283                }) => files.push(ContextPackRepositoryFileSource::utf8(
12284                    selected_path,
12285                    text,
12286                    byte_len,
12287                )),
12288                Ok(WorkspaceFileRead {
12289                    content: WorkspaceFileContent::NonUtf8 { byte_len },
12290                    ..
12291                }) => files.push(ContextPackRepositoryFileSource::skipped(
12292                    selected_path,
12293                    ContextPackSelectedFileSkipReason::NonUtf8,
12294                    Some(byte_len),
12295                )),
12296                Ok(WorkspaceFileRead {
12297                    content: WorkspaceFileContent::TooLarge { .. },
12298                    ..
12299                }) => files.push(ContextPackRepositoryFileSource::skipped(
12300                    selected_path,
12301                    ContextPackSelectedFileSkipReason::TooLarge,
12302                    None,
12303                )),
12304                Err(error) => {
12305                    let reason = if matches!(
12306                        error.code,
12307                        NodeFailureCode::InvalidRepositoryPath
12308                            | NodeFailureCode::RepositoryFileNotRegular
12309                            | NodeFailureCode::RepositoryPathUnsafe
12310                    ) {
12311                        ContextPackSelectedFileSkipReason::Unsafe
12312                    } else {
12313                        ContextPackSelectedFileSkipReason::Unavailable
12314                    };
12315                    files.push(ContextPackRepositoryFileSource::skipped(
12316                        selected_path,
12317                        reason,
12318                        None,
12319                    ));
12320                }
12321            }
12322        }
12323        Ok(ContextPackRepositoryObservation { head, git, files })
12324    }
12325
12326    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12327        let referenced_by_binding = self
12328            .session_bindings
12329            .lock()
12330            .unwrap_or_else(|poisoned| poisoned.into_inner())
12331            .values()
12332            .any(|binding| {
12333                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12334            });
12335        let referenced_by_record = self
12336            .session_records
12337            .lock()
12338            .unwrap_or_else(|poisoned| poisoned.into_inner())
12339            .records
12340            .values()
12341            .any(|record| record.context_id.as_ref() == Some(context_id));
12342        let referenced_by_materialization = self
12343            .materializations
12344            .lock()
12345            .unwrap_or_else(|poisoned| poisoned.into_inner())
12346            .values()
12347            .any(|ownership| {
12348                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12349            });
12350        if referenced_by_binding
12351            || referenced_by_record
12352            || referenced_by_materialization
12353        {
12354            return Err(failure(
12355                NodeFailureCode::ContextPackBusy,
12356                "context pack is still referenced",
12357            ));
12358        }
12359        let mut catalog = self.context_catalog
12360            .write()
12361            .unwrap_or_else(|poisoned| poisoned.into_inner());
12362        if catalog.get(context_id).is_none() {
12363            return Err(failure(
12364                NodeFailureCode::UnknownContextPack,
12365                "context pack is unavailable",
12366            ));
12367        }
12368        self.spawn_idempotency
12369            .lock()
12370            .unwrap_or_else(|poisoned| poisoned.into_inner())
12371            .entries
12372            .retain(|_, entry| !entry.value.references_context(context_id));
12373        let removed = catalog.remove(context_id);
12374        debug_assert!(removed.is_some(), "validated context pack remains present");
12375        Ok(())
12376    }
12377
12378    async fn dispatch_input_bounded(
12379        &self,
12380        address: &SessionAddress,
12381        action: InputAction,
12382    ) -> Result<(), NodeFailure> {
12383        let started_after = self.current_sequence();
12384        self.pty_input_marks
12385            .lock()
12386            .unwrap_or_else(|poisoned| poisoned.into_inner())
12387            .insert(address.session.instance_id, (Instant::now(), false));
12388        self.pty_input_wake.notify_one();
12389        let command_id = self
12390            .dispatch_bounded(
12391                ControlCommand::SendInput {
12392                    instance_id: address.session.instance_id,
12393                    action,
12394                },
12395                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12396            )
12397            .await?;
12398        // A semantic prompt may spend the provider's full readiness budget before
12399        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12400        // but strictly beyond every readiness policy in this node's catalog.
12401        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12402        let mut scan_after = started_after;
12403        let mut accepted_after = None;
12404        loop {
12405            let events = self
12406                .history
12407                .lock()
12408                .unwrap_or_else(|poisoned| poisoned.into_inner())
12409                .events
12410                .iter()
12411                .filter(|envelope| envelope.sequence > scan_after)
12412                .cloned()
12413                .collect::<Vec<_>>();
12414            for envelope in events {
12415                scan_after = scan_after.max(envelope.sequence);
12416                let NodeEvent::Control {
12417                    address: event_address,
12418                    event,
12419                } = envelope.event
12420                else {
12421                    continue;
12422                };
12423                if event_address != *address {
12424                    continue;
12425                }
12426                if event.command_id == Some(command_id) {
12427                    match &event.event {
12428                        ControlEventKind::CommandRejected { message } => {
12429                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12430                        }
12431                        ControlEventKind::InputRequested { .. } => {
12432                            accepted_after = Some(envelope.sequence);
12433                            continue;
12434                        }
12435                        _ => {}
12436                    }
12437                }
12438                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12439                    match &event.event {
12440                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12441                        ControlEventKind::InputFailed { message, .. } => {
12442                            return Err(failure(
12443                                NodeFailureCode::BackendOperationFailed,
12444                                message,
12445                            ));
12446                        }
12447                        _ => {}
12448                    }
12449                }
12450            }
12451            if Instant::now() >= deadline {
12452                return Err(failure(
12453                    NodeFailureCode::BackendBusy,
12454                    if accepted_after.is_some() {
12455                        "input was accepted but did not settle before the bounded deadline"
12456                    } else {
12457                        "input was not accepted before the bounded deadline"
12458                    },
12459                ));
12460            }
12461            sleep(Duration::from_millis(2)).await;
12462        }
12463    }
12464
12465    async fn dispatch_resize_bounded(
12466        &self,
12467        address: &SessionAddress,
12468        size: gate4agent_types::TerminalSize,
12469    ) -> Result<(), NodeFailure> {
12470        let started_after = self.current_sequence();
12471        let command_id = self
12472            .dispatch_bounded(
12473                ControlCommand::Resize {
12474                    instance_id: address.session.instance_id,
12475                    size,
12476                },
12477                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12478            )
12479            .await?;
12480        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12481        let mut scan_after = started_after;
12482        let mut accepted_after = None;
12483        loop {
12484            let events = self
12485                .history
12486                .lock()
12487                .unwrap_or_else(|poisoned| poisoned.into_inner())
12488                .events
12489                .iter()
12490                .filter(|envelope| envelope.sequence > scan_after)
12491                .cloned()
12492                .collect::<Vec<_>>();
12493            for envelope in events {
12494                scan_after = scan_after.max(envelope.sequence);
12495                let NodeEvent::Control {
12496                    address: event_address,
12497                    event,
12498                } = envelope.event
12499                else {
12500                    continue;
12501                };
12502                if event_address != *address {
12503                    continue;
12504                }
12505                if event.command_id == Some(command_id) {
12506                    match &event.event {
12507                        ControlEventKind::CommandRejected { message } => {
12508                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12509                        }
12510                        ControlEventKind::ResizeRequested {
12511                            size: requested_size,
12512                            ..
12513                        } if *requested_size == size => {
12514                            accepted_after = Some(envelope.sequence);
12515                            continue;
12516                        }
12517                        _ => {}
12518                    }
12519                }
12520                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12521                    match &event.event {
12522                        ControlEventKind::Resized { size: settled_size }
12523                            if *settled_size == size =>
12524                        {
12525                            return Ok(());
12526                        }
12527                        ControlEventKind::ResizeFailed { message } => {
12528                            return Err(failure(
12529                                NodeFailureCode::BackendOperationFailed,
12530                                message,
12531                            ));
12532                        }
12533                        _ => {}
12534                    }
12535                }
12536            }
12537            if Instant::now() >= deadline {
12538                return Err(failure(
12539                    NodeFailureCode::BackendBusy,
12540                    if accepted_after.is_some() {
12541                        "resize was accepted but did not settle before the bounded deadline"
12542                    } else {
12543                        "resize was not accepted before the bounded deadline"
12544                    },
12545                ));
12546            }
12547            sleep(Duration::from_millis(2)).await;
12548        }
12549    }
12550
12551    async fn spawn_session(
12552        &self,
12553        workspace_id: WorkspaceId,
12554        provider: AgentId,
12555        mode: SessionMode,
12556        terminal_size: gate4agent_types::TerminalSize,
12557        initial_prompt: Option<String>,
12558    ) -> Result<SessionAddress, NodeFailure> {
12559        if self.managed_worktrees.lock()
12560            .unwrap_or_else(|poisoned| poisoned.into_inner())
12561            .lease_for_workspace(&workspace_id)
12562            .is_some()
12563        {
12564            return Err(failure(
12565                NodeFailureCode::ManagedWorktreeOwnershipConflict,
12566                "managed worktree targets may only be spawned through their lease request",
12567            ));
12568        }
12569        self
12570            .spawn_session_with_deadline(
12571                workspace_id,
12572                provider,
12573                mode,
12574                terminal_size,
12575                initial_prompt,
12576                None,
12577                None,
12578                None,
12579                None,
12580                None,
12581                SpawnRecordPolicy::ProviderIdentityOnly,
12582                None,
12583                &[],
12584                None,
12585                // `NodeRequest::Spawn` (this method's only caller) carries no
12586                // approval-level selection of its own -- see
12587                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
12588                // the one operator-wire path that can choose something other
12589                // than the axis default.
12590                ApprovalLevel::default(),
12591                None,
12592                None,
12593            )
12594            .await
12595            .map(|(session, _runtime_policy)| session)
12596    }
12597
12598    async fn spawn_session_with_deadline(
12599        &self,
12600        workspace_id: WorkspaceId,
12601        provider: AgentId,
12602        mode: SessionMode,
12603        terminal_size: gate4agent_types::TerminalSize,
12604        initial_prompt: Option<String>,
12605        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12606        bundle: Option<ResolvedBundleReceipt>,
12607        context: Option<ResolvedContextPackReceipt>,
12608        managed_authority: Option<ManagedWorktreeLeaseId>,
12609        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12610        record_policy: SpawnRecordPolicy,
12611        deadline: Option<Instant>,
12612        required_capabilities: &[ProviderRuntimeCapability],
12613        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12614        approval_level: ApprovalLevel,
12615        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12616        // Opaque dig2browser station profile id (ids only). Feature
12617        // `dig2-station-probe`: after probe at resolve, spawn takes an
12618        // exclusive node-local lease; cleared on session end. Never
12619        // ImportSession / cookies on C2. Feature off: ignored (stub echo
12620        // already happened on the receipt path).
12621        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12622    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12623        let reserved_lease = self.managed_worktrees.lock()
12624            .unwrap_or_else(|poisoned| poisoned.into_inner())
12625            .lease_for_workspace(&workspace_id);
12626        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12627            (Some(expected), Some(provided)) if expected == provided => {}
12628            (None, None) => {}
12629            _ => {
12630                return Err(failure(
12631                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
12632                    "spawn lacks exact managed worktree lease authority",
12633                ));
12634            }
12635        }
12636        // Refuse concurrent distinct environment profiles (incl. ProviderHome
12637        // bindings) on the same workspace cwd. Parallel path: managed worktree
12638        // spawn so each profile gets its own workspace_id.
12639        self.ensure_workspace_environment_profile_compatible(
12640            &workspace_id,
12641            environment_profile.as_ref(),
12642        )?;
12643        let runtime_requirement = match (mode, initial_prompt.is_some()) {
12644            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12645            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12646            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12647            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12648        };
12649        // This session's own transport -- computed once, here, from `mode`,
12650        // and reused both to admit spawn (below) and, further down, to
12651        // compute the final `runtime_policy` this session actually operates
12652        // under (`provider_runtime::policy_for_transport`). Registration
12653        // reuses this exact value rather than recomputing it a second time.
12654        let transport = match mode {
12655            SessionMode::Pty => TransportKind::Pty,
12656            SessionMode::Inline => TransportKind::Pipe,
12657            SessionMode::Acp => TransportKind::Acp,
12658        };
12659        let pipe_transport = self.provider_supports_pipe_transport(&provider);
12660        let acp_transport = self.provider_supports_acp_transport(&provider);
12661        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12662            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12663                NodeFailureCode::UnsupportedSpawnCapability,
12664                "pre-admitted provider runtime no longer satisfies spawn requirements",
12665            ))?;
12666            runtime_policy
12667        } else if let Some(deadline) = deadline {
12668            let remaining = spawn_deadline_remaining(deadline)?;
12669            timeout(
12670                remaining,
12671                self.admit_provider_runtime(&provider, runtime_requirement),
12672            )
12673            .await
12674            .map_err(|_| {
12675                failure(
12676                    NodeFailureCode::SpawnDeadlineExceeded,
12677                    "provider admission exceeded the spawn deadline",
12678                )
12679            })??
12680        } else {
12681            self.admit_provider_runtime(&provider, runtime_requirement).await?
12682        };
12683        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12684        // The single point where this session's `runtime_policy` becomes
12685        // transport-correct: `admit_provider_runtime` (and the pre-admitted
12686        // branch above) only ever compute a PTY-terminal-verification-shaped
12687        // value, sound outright only for `TransportKind::Pty` (Pipe still
12688        // relies on that same value downstream -- see `policy_for_transport`'s
12689        // own doc for why). `policy_for_transport` is the explicit,
12690        // exhaustive match Item 1 asks for -- this is the ONE place its
12691        // output becomes the session's `runtime_policy`, so every later
12692        // reader (`ControlCommand::Start`, `session_bindings`, and every
12693        // engine gate that reads `state.runtime_policy`) sees the same,
12694        // correctly-computed value.
12695        let runtime_policy =
12696            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12697        if required_capabilities
12698            .iter()
12699            .any(|capability| !runtime_policy.admits(*capability))
12700        {
12701            return Err(failure(
12702                NodeFailureCode::UnsupportedSpawnCapability,
12703                "provider runtime does not admit a required spawn capability",
12704            ));
12705        }
12706        if let Some(deadline) = deadline {
12707            spawn_deadline_remaining(deadline)?;
12708        }
12709        self.ensure_binding_capacity()?;
12710        let working_directory = self.workspace_root(&workspace_id)?;
12711        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12712        let session = SessionKey {
12713            instance_id,
12714            generation: SessionGeneration(1),
12715        };
12716        let address = SessionAddress {
12717            workspace_id: workspace_id.clone(),
12718            session,
12719        };
12720        // This is the request-accepted checkpoint: admission (managed
12721        // worktree authority, runtime capability, dispatch deadline) already
12722        // passed and a concrete session identity is minted. It is a distinct
12723        // state from the process actually running (`ControlEventKind::Running`,
12724        // logged in `publish_control`) or the terminal outcome (`Exited`/
12725        // `Failed`, logged there too) -- this line exists so that gap is no
12726        // longer silent.
12727        tracing::info!(
12728            node_id = %self.node_id,
12729            workspace_id = %workspace_id,
12730            provider = %provider,
12731            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12732            session = ?address.session,
12733            "session spawn request accepted",
12734        );
12735        let mut prepared_spawn = SpawnPreparer::prepare(
12736            self,
12737            SpawnPrepareRequest {
12738                instance_id,
12739                address: &address,
12740                provider: &provider,
12741                mode,
12742                environment_profile: environment_profile.as_ref(),
12743                bundle: bundle.as_ref(),
12744                context: context.as_ref(),
12745                managed_authority: managed_authority.as_ref(),
12746                harness_mcp,
12747                approval_level,
12748                network_allowlist: network_allowlist.as_ref(),
12749                browser_profile_id,
12750            },
12751        )?;
12752        let materialization_id = prepared_spawn.materialization_id.take();
12753        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12754        let record_id = self.bind_spawn_session_with_materialization(
12755            &address,
12756            provider.clone(),
12757            mode,
12758            runtime_policy,
12759            record_policy,
12760            environment_profile,
12761            bundle,
12762            context,
12763            materialization_id,
12764        )?;
12765        prepared_spawn.retain_session_guards();
12766        // `transport` was already computed above, alongside this session's
12767        // `runtime_policy`; reused here for `Register` rather than derived a
12768        // second time.
12769        let agent_id = provider;
12770        let register_command_id = match self.dispatch_bounded(
12771            ControlCommand::Register {
12772                instance_id,
12773                agent_id,
12774                transport,
12775            },
12776            dispatch_timeout,
12777        )
12778        .await {
12779            Ok(command_id) => command_id,
12780            Err(error) => {
12781                if let Some(binding) = self.remove_binding(&address) {
12782                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12783                }
12784                if let Some(record_id) = record_id.as_ref() {
12785                    self.discard_record(record_id)?;
12786                }
12787                return Err(spawn_dispatch_error(error, deadline));
12788            }
12789        };
12790        let start_timeout = match spawn_dispatch_timeout(deadline) {
12791            Ok(timeout) => timeout,
12792            Err(error) => {
12793                let recovery = self
12794                    .rollback_spawn(
12795                        &address,
12796                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12797                    )
12798                    .await;
12799                return match recovery {
12800                    Ok(()) => {
12801                        if let Some(record_id) = record_id.as_ref() {
12802                            self.discard_record(record_id)?;
12803                        }
12804                        Err(error)
12805                    }
12806                    Err(recovery_error) => {
12807                        if let Some(record_id) = record_id.as_ref() {
12808                            self.mark_record_error(
12809                                record_id,
12810                                "spawn deadline elapsed and registration recovery failed",
12811                            )?;
12812                        }
12813                        Err(recovery_error)
12814                    }
12815                };
12816            }
12817        };
12818        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12819            if let Err(recovery_error) = self
12820                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12821                .await
12822            {
12823                return Err(recovery_error);
12824            }
12825            if let Some(record_id) = record_id.as_ref() {
12826                self.discard_record(record_id)?;
12827            }
12828            return Err(start_error);
12829        }
12830        let start_result = self
12831            .dispatch_bounded(
12832                ControlCommand::Start {
12833                    instance_id,
12834                    runtime_policy,
12835                    request: StartRequest {
12836                        working_directory,
12837                        terminal_size,
12838                        initial_prompt,
12839                        session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
12840                        approval_level,
12841                    },
12842                },
12843                start_timeout,
12844            )
12845            .await;
12846        let start_command_id = match start_result {
12847            Ok(command_id) => command_id,
12848            Err(start_error) => {
12849                let start_error = spawn_dispatch_error(start_error, deadline);
12850                let recovery = self
12851                    .rollback_spawn(
12852                        &address,
12853                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12854                    )
12855                    .await;
12856                return match recovery {
12857                    Ok(()) => {
12858                        if let Some(record_id) = record_id.as_ref() {
12859                            self.discard_record(record_id)?;
12860                        }
12861                        Err(start_error)
12862                    }
12863                    Err(recovery_error) => {
12864                        let diagnostic = format!(
12865                            "start failed and registration recovery failed: {}",
12866                            recovery_error.message,
12867                        );
12868                        if let Some(record_id) = record_id.as_ref() {
12869                            self.mark_record_error(record_id, &diagnostic)?;
12870                        }
12871                        Err(failure(recovery_error.code, &diagnostic))
12872                    }
12873                };
12874            }
12875        };
12876
12877        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12878        loop {
12879            // `Register`/`Start` only fail synchronously for a port-level
12880            // ingress problem (see `dispatch_bounded`); the kernel refusing
12881            // either command's content -- an unsupported transport, an
12882            // unknown agent -- lands one drive-loop tick later, and no
12883            // session ever appears in `self.handle.snapshot()` for it to
12884            // notice below. Check for that named reason before falling
12885            // through to the blind presence poll, so a rejected spawn
12886            // reports why instead of just timing out.
12887            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12888                .or_else(|| self.take_rejected_command_reason(start_command_id))
12889            {
12890                self.rollback_spawn(
12891                    &address,
12892                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12893                ).await.map_err(|error| {
12894                        failure(
12895                            error.code,
12896                            &format!(
12897                                "spawn was rejected ({}) and registration recovery failed: {}",
12898                                reason.message, error.message
12899                            ),
12900                        )
12901                    })?;
12902                if let Some(record_id) = record_id.as_ref() {
12903                    self.discard_record(record_id)?;
12904                }
12905                let code = if reason.unsupported_transport {
12906                    NodeFailureCode::UnsupportedTransport
12907                } else {
12908                    NodeFailureCode::BackendOperationFailed
12909                };
12910                return Err(failure(code, &reason.message));
12911            }
12912            if let Some(current) = self
12913                .handle
12914                .snapshot()
12915                .sessions
12916                .iter()
12917                .find(|current| current.instance_id == instance_id)
12918            {
12919                if current.generation != session.generation {
12920                    let divergence = failure(
12921                        NodeFailureCode::StaleGeneration,
12922                        "spawn generation diverged",
12923                    );
12924                    return match self
12925                        .rollback_spawn(
12926                            &address,
12927                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12928                        )
12929                        .await
12930                    {
12931                        Ok(()) => {
12932                            if let Some(record_id) = record_id.as_ref() {
12933                                self.discard_record(record_id)?;
12934                            }
12935                            Err(divergence)
12936                        }
12937                        Err(recovery_error) => {
12938                            if let Some(record_id) = record_id.as_ref() {
12939                                self.mark_record_error(
12940                                    record_id,
12941                                    "spawn generation diverged and recovery failed",
12942                                )?;
12943                            }
12944                            Err(recovery_error)
12945                        }
12946                    };
12947                }
12948                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12949                    prepared_spawn.retain_launch_guards();
12950                    return Ok((address, runtime_policy));
12951                }
12952            }
12953            if Instant::now() >= commit_deadline {
12954                self.rollback_spawn(
12955                    &address,
12956                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12957                ).await.map_err(|error| {
12958                        failure(
12959                            error.code,
12960                            &format!(
12961                                "start did not commit and registration recovery failed: {}",
12962                                error.message
12963                            ),
12964                        )
12965                    })?;
12966                if let Some(record_id) = record_id.as_ref() {
12967                    self.discard_record(record_id)?;
12968                }
12969                return Err(failure(
12970                    if deadline.is_some() {
12971                        NodeFailureCode::SpawnDeadlineExceeded
12972                    } else {
12973                        NodeFailureCode::BackendBusy
12974                    },
12975                    "start did not commit before the bounded deadline; registration was removed",
12976                ));
12977            }
12978            sleep(Duration::from_millis(2)).await;
12979        }
12980    }
12981
12982    fn effective_spawn_runtime_policy(
12983        &self,
12984        runtime_policy: ProviderRuntimePolicy,
12985    ) -> ProviderRuntimePolicy {
12986        #[cfg(feature = "fixture")]
12987        let runtime_policy = if self.fixture_semantic_hook_policy {
12988            ProviderRuntimePolicy::new(true, true, true, true, false, true)
12989                .expect("monitoring Hook fixture policy is internally valid")
12990        } else {
12991            runtime_policy
12992        };
12993        runtime_policy
12994    }
12995
12996    fn verify_harness_mcp_before_start(
12997        &self,
12998        prepared: Option<&PreparedHarnessMcpSpawn>,
12999    ) -> Result<(), NodeFailure> {
13000        prepared
13001            .map(PreparedHarnessMcpSpawn::verify_helper_program)
13002            .transpose()
13003            .map(|_| ())
13004            .map_err(harness_mcp_failure)
13005    }
13006
13007    async fn rollback_spawn(
13008        &self,
13009        address: &SessionAddress,
13010        commit_timeout: Duration,
13011    ) -> Result<(), NodeFailure> {
13012        self.dispatch_bounded(
13013            ControlCommand::Remove {
13014                instance_id: address.session.instance_id,
13015            },
13016            commit_timeout,
13017        )
13018        .await?;
13019        self.wait_until_removed(address, commit_timeout).await?;
13020        if let Some(binding) = self.remove_binding(address) {
13021            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13022        }
13023        Ok(())
13024    }
13025
13026    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13027        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13028        self.dispatch_bounded(
13029            ControlCommand::Remove {
13030                instance_id: address.session.instance_id,
13031            },
13032            commit_timeout,
13033        )
13034            .await?;
13035        self.wait_until_removed(address, commit_timeout).await?;
13036        let removed = self.remove_binding(address);
13037        if let Some(binding) = removed.as_ref() {
13038            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13039        }
13040        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13041            let snapshot = self.managed_worktrees.lock()
13042                .unwrap_or_else(|poisoned| poisoned.into_inner())
13043                .release_session(
13044                    &lease_id,
13045                    self.incarnation_id,
13046                    address.session.instance_id,
13047                    address.session.generation,
13048                    unix_time_ms(),
13049                );
13050            if self.persist_state().is_err() {
13051                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13052            }
13053            if let Some(snapshot) = snapshot {
13054                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13055            }
13056            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13057        }
13058        Ok(())
13059    }
13060
13061    /// Polls until `address`'s session leaves the engine's own session map,
13062    /// or the bounded deadline expires.
13063    ///
13064    /// A `Remove` sent while the session is not yet in a terminal status
13065    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
13066    /// once by the engine (`SessionStatus::allows_remove`) and
13067    /// then discarded -- nothing re-issues it on its own, so the removal
13068    /// that would satisfy this wait never arrives and the caller was
13069    /// guaranteed to time out even though the session settles microseconds
13070    /// later. So this re-dispatches on every poll tick: a session that
13071    /// settles mid-wait -- the common case right after a `Stop` -- still
13072    /// gets removed inside the same bounded deadline.
13073    ///
13074    /// It re-dispatches only once the status will ACCEPT the command. A
13075    /// rejected re-dispatch is not the harmless no-op this loop first
13076    /// assumed: every rejection publishes a `ControlEventKind::Command
13077    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
13078    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
13079    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
13080    /// out to the harness and every attached client. Gated, the same
13081    /// teardown sends exactly one `Remove`, at the first tick the engine
13082    /// can act on it.
13083    ///
13084    /// The status is read from the snapshot this loop already takes, so
13085    /// the gate costs nothing extra. It races -- the status can move
13086    /// between the read and the dispatch -- and that is fine: a rare lost
13087    /// race is one rejection, and the next tick retries.
13088    ///
13089    /// Re-dispatch is separately rate-limited to
13090    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
13091    /// leaves a second burst behind it. Once the session settles, every
13092    /// 2ms tick sent another `Remove` while the accepted one was still in
13093    /// flight, and each of those landed on an instance the engine had
13094    /// already dropped -- `agent instance … is not registered`, observed
13095    /// live as a tail of rejections immediately after the `session
13096    /// removed` line. The presence poll stays at 2ms so the wait still
13097    /// exits promptly; only the sending slows down.
13098    ///
13099    /// Rate-limited rather than once-only on purpose: the status gate
13100    /// covers the status precondition, but `Gate4AgentEngine::remove` has
13101    /// others (a pending operation, an in-flight capability probe or
13102    /// history request) that clear on their own. A single shot would turn
13103    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
13104    /// get their retries, just twenty of them across the deadline instead
13105    /// of a thousand.
13106    async fn wait_until_removed(
13107        &self,
13108        address: &SessionAddress,
13109        commit_timeout: Duration,
13110    ) -> Result<(), NodeFailure> {
13111        let deadline = Instant::now() + commit_timeout;
13112        let mut next_dispatch = Instant::now();
13113        loop {
13114            let snapshot = self.handle.snapshot();
13115            let status = snapshot
13116                .sessions
13117                .iter()
13118                .find(|session| session.instance_id == address.session.instance_id)
13119                .map(|session| session.status.clone());
13120            let Some(status) = status else {
13121                return Ok(());
13122            };
13123            let now = Instant::now();
13124            if now >= deadline {
13125                return Err(failure(
13126                    NodeFailureCode::BackendBusy,
13127                    "remove did not commit before the bounded deadline; session binding was retained",
13128                ));
13129            }
13130            if status.allows_remove() && now >= next_dispatch {
13131                let _ = self.dispatch(ControlCommand::Remove {
13132                    instance_id: address.session.instance_id,
13133                });
13134                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13135            }
13136            sleep(Duration::from_millis(2)).await;
13137        }
13138    }
13139
13140    fn resync(&self, after_sequence: u64) -> NodeResponse {
13141        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13142        let event_sequence = history.last_sequence;
13143        let oldest_available_sequence = history.replay_floor_sequence;
13144        let events = history
13145            .events
13146            .iter()
13147            .filter(|event| event.sequence > after_sequence)
13148            .cloned()
13149            .collect();
13150        drop(history);
13151        NodeResponse::Resync {
13152            event_sequence,
13153            oldest_available_sequence,
13154            snapshot: self.snapshot(),
13155            events,
13156        }
13157    }
13158}
13159
13160/// Coarse label for one `AgentStreamChunkKindV1`, used only by
13161/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
13162/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
13163/// "name what crossed this boundary" reasoning, one layer upstream of it).
13164fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13165    match kind {
13166        AgentStreamChunkKindV1::Text { .. } => "text",
13167        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13168        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13169        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13170        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13171        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13172        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13173    }
13174}
13175
13176/// Serves this wire on the local endpoint, and -- when `call_home` names a
13177/// relay -- over an outbound connection to it as well.
13178///
13179/// The two live in one future rather than a fourth arm of `run`'s own
13180/// `select!` because they are one concern: this is where the node's wire
13181/// is served, and the direction a given connection was opened in does not
13182/// change what happens on it. It also means the existing graceful drain
13183/// below covers both without being duplicated.
13184///
13185/// Either side finishing ends the pair, which is correct: the listener
13186/// only returns on shutdown or a bind failure, and `call_home_loop` only
13187/// returns on shutdown -- a failed relay connection is retried inside it,
13188/// never propagated out.
13189async fn accept_connections(
13190    endpoint: &str,
13191    call_home: Option<std::net::SocketAddr>,
13192    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13193    shared: Arc<NodeShared>,
13194) -> Result<(), NodeServerError> {
13195    let mut connections = JoinSet::new();
13196    let result = match call_home {
13197        None => accept_connections_inner(endpoint, shared, &mut connections).await,
13198        Some(relay) => {
13199            let listener =
13200                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13201            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13202            tokio::pin!(listener);
13203            tokio::pin!(dialler);
13204            tokio::select! {
13205                result = &mut listener => result,
13206                result = &mut dialler => result,
13207            }
13208        }
13209    };
13210    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13211    while !connections.is_empty() {
13212        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13213        if remaining.is_zero()
13214            || timeout(remaining, connections.join_next()).await.is_err()
13215        {
13216            break;
13217        }
13218    }
13219    connections.abort_all();
13220    while connections.join_next().await.is_some() {}
13221    result
13222}
13223
13224/// Backoff ladder for a relay that will not answer, capped rather than
13225/// unbounded: a node whose relay is down is not broken and should keep
13226/// trying forever, but it should not spend a core doing it.
13227const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13228
13229/// Dials the relay, serves this wire over the connection, and does it
13230/// again when the connection ends.
13231///
13232/// The node is the SERVER on the socket it just opened. That inversion is
13233/// the whole point: `serve_connection` is the same function the local
13234/// listener feeds, and it neither knows nor cares which end placed the
13235/// call. The one thing this direction adds is the preface -- the relay
13236/// accepted a socket and cannot know whose it is, so the node says its
13237/// name before the handshake it would otherwise begin with.
13238///
13239/// Never returns an error. A relay that is down, refusing, or speaking a
13240/// different protocol is a condition to retry, not a reason to take the
13241/// node's local endpoint down with it; the only way out is shutdown.
13242async fn call_home_loop(
13243    relay: std::net::SocketAddr,
13244    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13245    shared: Arc<NodeShared>,
13246) -> Result<(), NodeServerError> {
13247    let mut failures = 0_usize;
13248    loop {
13249        if shared.shutdown.load(Ordering::Acquire) {
13250            return Ok(());
13251        }
13252        let permit = tokio::select! {
13253            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13254                permit.map_err(|_| NodeServerError::PreauthClosed)?
13255            }
13256            _ = shared.shutdown_notify.notified() => return Ok(()),
13257        };
13258        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13259            Ok(()) => {
13260                // A clean end is the relay hanging up, which it does on
13261                // its own restart. Reconnect immediately rather than
13262                // serving the backoff a peer that is plainly there.
13263                failures = 0;
13264                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13265            }
13266            Err(error) => {
13267                failures = failures.saturating_add(1);
13268                tracing::warn!(
13269                    node_id = %shared.node_id,
13270                    %relay,
13271                    cause = %error,
13272                    attempt = failures,
13273                    "call-home connection failed",
13274                );
13275                let backoff = call_home_backoff(failures);
13276                tokio::select! {
13277                    _ = sleep(backoff) => {}
13278                    _ = shared.shutdown_notify.notified() => return Ok(()),
13279                }
13280            }
13281        }
13282    }
13283}
13284
13285/// Idle time before the first keepalive probe on a call-home socket.
13286const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13287/// Interval between keepalive probes once the link has gone quiet.
13288const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13289
13290fn call_home_keepalive() -> socket2::TcpKeepalive {
13291    let keepalive = socket2::TcpKeepalive::new()
13292        .with_time(CALL_HOME_KEEPALIVE_IDLE)
13293        .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13294    #[cfg(not(windows))]
13295    let keepalive = keepalive.with_retries(3);
13296    keepalive
13297}
13298
13299fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13300    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13301}
13302
13303/// Same keepalive profile for any node->relay socket (restream uplink).
13304pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13305    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13306}
13307
13308/// Backoff for the `attempt`-th consecutive failure (1-based), capped.
13309fn call_home_backoff(attempt: usize) -> Duration {
13310    Duration::from_millis(
13311        CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13312    )
13313}
13314
13315/// One call-home attempt: connect, name this node, then serve.
13316async fn dial_relay_and_serve(
13317    relay: std::net::SocketAddr,
13318    identity: Option<&hatchery_enroll::NodeIdentity>,
13319    shared: Arc<NodeShared>,
13320    preauth_permit: OwnedSemaphorePermit,
13321) -> Result<(), NodeServerError> {
13322    let mut stream = tokio::net::TcpStream::connect(relay)
13323        .await
13324        .map_err(NodeServerError::CallHomeTransport)?;
13325    // Nagle off for the same reason the rest of this wire wants it off:
13326    // the traffic is small request/response frames, and coalescing them
13327    // adds latency to every one.
13328    let _ = stream.set_nodelay(true);
13329    // A relay that vanishes without a FIN/RST reaching us (tunnel drop,
13330    // relay-side socket kill whose RST was lost) would otherwise leave this
13331    // socket ESTABLISHED forever: the node mostly reads, so nothing ever
13332    // fails and the reconnect loop never runs. Kernel keepalive turns that
13333    // half-open link into a read error within a bounded time.
13334    if let Err(error) = enable_call_home_keepalive(&stream) {
13335        tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13336    }
13337    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13338        .await
13339        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13340    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13341    let mut session_token = None;
13342    if let Some(identity) = identity {
13343        // An enrolled relay answers the announce with a signed-call-home challenge; a
13344        // token-mode relay sends its wire hello instead. Peek to tell them apart
13345        // without consuming either.
13346        let mut magic = [0_u8; 4];
13347        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13348            .await
13349            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13350            .map_err(NodeServerError::CallHomeTransport)?;
13351        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13352            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13353            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13354                .await
13355                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13356                .map_err(NodeServerError::CallHomeTransport)?;
13357            let (answer, token) = identity
13358                .answer_challenge(shared.node_id.as_str(), &frame)
13359                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13360            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13361            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13362            session_token = Some(token);
13363        }
13364    }
13365    serve_connection(stream, shared, session_token, preauth_permit).await
13366}
13367
13368async fn accept_connections_inner(
13369    endpoint: &str,
13370    shared: Arc<NodeShared>,
13371    connections: &mut JoinSet<Result<(), NodeServerError>>,
13372) -> Result<(), NodeServerError> {
13373    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13374    loop {
13375        while connections.try_join_next().is_some() {}
13376        if shared.shutdown.load(Ordering::Acquire) {
13377            return Ok(());
13378        }
13379        let preauth_permit = tokio::select! {
13380            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13381                permit.map_err(|_| NodeServerError::PreauthClosed)?
13382            }
13383            _ = shared.shutdown_notify.notified() => return Ok(()),
13384        };
13385        let server = tokio::select! {
13386            result = listener.accept() => result?,
13387            _ = shared.shutdown_notify.notified() => return Ok(()),
13388        };
13389        let connection_shared = Arc::clone(&shared);
13390        connections.spawn(async move {
13391            serve_connection(server, connection_shared, None, preauth_permit).await
13392        });
13393        if shared.shutdown.load(Ordering::Acquire) {
13394            return Ok(());
13395        }
13396    }
13397}
13398
13399/// Serves one connection, whichever end opened it.
13400///
13401/// Generic over the stream rather than typed to `LocalServerStream`
13402/// because a call-home connection arrives on a `TcpStream` the node itself
13403/// dialled. Nothing in the body ever depended on the concrete type -- it
13404/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13405/// the whole of what welding "who dials" to "who serves" was costing.
13406async fn serve_connection<S>(
13407    mut pipe: S,
13408    shared: Arc<NodeShared>,
13409    session_token: Option<String>,
13410    preauth_permit: OwnedSemaphorePermit,
13411) -> Result<(), NodeServerError>
13412where
13413    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13414{
13415    // A signed call-home connection authenticates with its own per-connection token.
13416    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13417    let hello_frame = timeout(
13418        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13419        read_json_frame_limited_body_timeout(
13420            &mut pipe,
13421            MAX_NODE_HELLO_FRAME_BYTES,
13422            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13423        ),
13424    )
13425    .await
13426    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13427    let ClientFrame::Hello(hello) = hello_frame else {
13428        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13429    };
13430    if hello.build_stamp != BUILD_STAMP {
13431        return Err(NodeServerError::Handshake(format!(
13432            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13433            hello.build_stamp,
13434        )));
13435    }
13436    let compatibility = match hello.compatibility.as_ref() {
13437        Some(offer) => {
13438            let mut selected = node_compatibility_support(&shared)?
13439                .negotiate(offer)
13440                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13441            project_negotiated_provider_ids(&mut selected);
13442            Some(selected)
13443        }
13444        None => None,
13445    };
13446    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13447    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13448        (Some(offer), Some(selected)) => negotiated_auth_proof(
13449            auth_token,
13450            AuthDirection::Server,
13451            hello.role,
13452            &hello.client_nonce,
13453            &server_nonce,
13454            offer,
13455            selected,
13456        ),
13457        (None, None) => auth_proof(
13458            auth_token,
13459            AuthDirection::Server,
13460            hello.role,
13461            &hello.client_nonce,
13462            &server_nonce,
13463        ),
13464        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13465    }
13466    .map_err(NodeServerError::Authentication)?;
13467    write_json_frame_limited(
13468        &mut pipe,
13469        &ServerFrame::Challenge(ServerChallenge {
13470            build_stamp: BUILD_STAMP.to_owned(),
13471            server_nonce,
13472            server_proof,
13473            compatibility: compatibility.clone(),
13474        }),
13475        MAX_NODE_HELLO_FRAME_BYTES,
13476    )
13477    .await?;
13478    let authentication = timeout(
13479        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13480        read_json_frame_limited_body_timeout(
13481            &mut pipe,
13482            MAX_NODE_HELLO_FRAME_BYTES,
13483            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13484        ),
13485    )
13486    .await
13487    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13488    let ClientFrame::Authenticate(authentication) = authentication else {
13489        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13490    };
13491    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13492        (Some(offer), Some(selected)) => negotiated_auth_proof(
13493            auth_token,
13494            AuthDirection::Client,
13495            hello.role,
13496            &hello.client_nonce,
13497            &server_nonce,
13498            offer,
13499            selected,
13500        ),
13501        (None, None) => auth_proof(
13502            auth_token,
13503            AuthDirection::Client,
13504            hello.role,
13505            &hello.client_nonce,
13506            &server_nonce,
13507        ),
13508        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13509    }
13510    .map_err(NodeServerError::Authentication)?;
13511    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13512        return Err(NodeServerError::Handshake("access denied".to_owned()));
13513    }
13514    let selected_capabilities = compatibility
13515        .as_ref()
13516        .map(|selected| selected.capabilities.clone())
13517        .unwrap_or_default();
13518    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13519        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13520    });
13521    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13522        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13523    });
13524    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13525        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13526    });
13527    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13528        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13529    });
13530    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13531        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13532    });
13533    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13534        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13535    }) && selected_capabilities.iter().any(|capability| {
13536        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13537    });
13538    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13539        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13540    });
13541    let include_session_bundles = selected_capabilities.iter().any(|capability| {
13542        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13543    });
13544    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13545        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13546    });
13547    let include_agent_progress = selected_capabilities.iter().any(|capability| {
13548        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13549    });
13550    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13551        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13552    });
13553    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13554        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13555    });
13556    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13557        .try_acquire_owned()
13558        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13559    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13560    let _release_guard = ControllerReleaseGuard {
13561        shared: Arc::clone(&shared),
13562        connection_id,
13563    };
13564    drop(preauth_permit);
13565    let _authenticated_permit = authenticated_permit;
13566    let mut event_rx = shared.event_tx.subscribe();
13567    let mut terminal_event_rx = terminal_event_subscription(
13568        &shared,
13569        include_terminal_frame_events,
13570    );
13571    let mut agent_stream_event_rx = agent_stream_event_subscription(
13572        &shared,
13573        include_agent_stream_events,
13574    );
13575    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13576        &shared,
13577        include_harness_mcp_proxy,
13578    );
13579    write_json_frame(
13580        &mut pipe,
13581        &ServerFrame::Hello(NodeHello {
13582            build_stamp: BUILD_STAMP.to_owned(),
13583            incarnation_id: shared.incarnation_id,
13584            connection_id,
13585            role: hello.role,
13586            event_sequence: shared.current_sequence(),
13587            controller: shared.controller_state(),
13588            snapshot: snapshot_for_wire(
13589                &shared,
13590                include_provider_runtime_status,
13591                include_open_provider_ids,
13592                include_spawn_profiles,
13593                include_managed_worktrees,
13594                include_child_environment_profiles,
13595                include_session_bundles,
13596                include_history_context_packs,
13597                include_agent_progress,
13598                include_session_task_correlation,
13599            ),
13600            compatibility,
13601        }),
13602    )
13603    .await?;
13604    let (mut reader, mut writer) = tokio::io::split(pipe);
13605    let (frame_tx, mut frame_rx) = mpsc::channel(16);
13606    let reader_task = tokio::spawn(async move {
13607        loop {
13608            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13609                &mut reader,
13610                MAX_NODE_CLIENT_FRAME_BYTES,
13611                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13612            )
13613            .await;
13614            let terminal = frame.is_err();
13615            if frame_tx.send(frame).await.is_err() || terminal {
13616                break;
13617            }
13618        }
13619    });
13620    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13621    let mut pending_events = Vec::new();
13622    let mut resync_required = false;
13623    let mut discard_events_through = 0_u64;
13624    loop {
13625        // One tick of the serve loop, whether or not it had anything to do.
13626        // Against `connection_events_sent` this separates "busy" from
13627        // "spinning": a loop turning over thousands of times a second while
13628        // sending nothing is not serving, it is burning a core.
13629        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13630        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13631            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13632        for _ in 0..durable_drain_budget {
13633            match event_rx.try_recv() {
13634                Ok(event) => queue_connection_event(
13635                    &mut pending_events,
13636                    event,
13637                    discard_events_through,
13638                ),
13639                Err(broadcast::error::TryRecvError::Empty) => break,
13640                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13641                    resync_required = true;
13642                    break;
13643                }
13644                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13645            }
13646        }
13647        if let Some(receiver) = terminal_event_rx.as_mut() {
13648            match receiver.try_recv() {
13649                Ok(events) => queue_connection_event_batch(
13650                    &mut pending_events,
13651                    &events,
13652                    discard_events_through,
13653                ),
13654                Err(broadcast::error::TryRecvError::Empty) => {}
13655                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13656                    resync_required = true;
13657                }
13658                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13659            }
13660        }
13661        if let Some(receiver) = agent_stream_event_rx.as_mut() {
13662            // Loops up to the same remaining-room budget the durable
13663            // `event_rx` drain above uses, rather than one `try_recv` per
13664            // tick: each successful receive here is itself only ONE
13665            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
13666            // sends `vec![envelope]`, never more), so a single-shot drain
13667            // caps this connection's agent-stream throughput at one chunk
13668            // per outer-loop tick regardless of how many are already queued
13669            // -- exactly the kind of self-inflicted stall this crate's own
13670            // durable-event budget exists to avoid.
13671            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13672                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13673            for _ in 0..agent_stream_drain_budget {
13674                match receiver.try_recv() {
13675                    Ok(events) => queue_connection_event_batch(
13676                        &mut pending_events,
13677                        &events,
13678                        discard_events_through,
13679                    ),
13680                    Err(broadcast::error::TryRecvError::Empty) => break,
13681                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13682                        tracing::warn!(
13683                            connection_id,
13684                            dropped,
13685                            "agent stream event subscription lagged; forcing a resync",
13686                        );
13687                        resync_required = true;
13688                        break;
13689                    }
13690                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13691                }
13692            }
13693        }
13694        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13695            // Mirrors the agent-stream drain immediately above: each
13696            // successful receive is one single-envelope batch
13697            // (`publish_transient` always sends `vec![envelope]`), so this
13698            // loops up to the same remaining-room budget rather than
13699            // stopping after one `try_recv`.
13700            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13701                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13702            for _ in 0..harness_mcp_proxy_drain_budget {
13703                match receiver.try_recv() {
13704                    Ok(events) => queue_connection_event_batch(
13705                        &mut pending_events,
13706                        &events,
13707                        discard_events_through,
13708                    ),
13709                    Err(broadcast::error::TryRecvError::Empty) => break,
13710                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13711                        tracing::warn!(
13712                            connection_id,
13713                            dropped,
13714                            "harness MCP proxy event subscription lagged; forcing a resync",
13715                        );
13716                        resync_required = true;
13717                        break;
13718                    }
13719                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13720                }
13721            }
13722        }
13723        if resync_required {
13724            let event = resync_required_event(&shared);
13725            apply_connection_resync_watermark(
13726                &mut pending_events,
13727                &mut discard_events_through,
13728                event.sequence,
13729            );
13730            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13731            resync_required = false;
13732            continue;
13733        }
13734        if !pending_events.is_empty() {
13735            pending_events.sort_unstable_by_key(|event| event.sequence);
13736            let burst_len = connection_event_burst_len(pending_events.len());
13737            shared
13738                .connection_events_sent
13739                .fetch_add(burst_len as u64, Ordering::Relaxed);
13740            for event in pending_events.drain(..burst_len) {
13741                let event = if include_managed_worktrees {
13742                    Some(event)
13743                } else {
13744                    project_event_without_managed_worktrees(event)
13745                };
13746                let event = event.and_then(|event| if include_open_provider_ids {
13747                    Some(event)
13748                } else {
13749                    project_event_legacy_provider_ids(&shared, event)
13750                });
13751                let event = event.map(|event| if include_child_environment_profiles {
13752                    event
13753                } else {
13754                    project_event_without_child_environment_profile(event)
13755                });
13756                let event = event.map(|event| if include_session_bundles {
13757                    event
13758                } else {
13759                    project_event_without_session_bundle(event)
13760                });
13761                let event = event.map(|event| {
13762                    project_event_history_for_wire(event, include_history_context_packs)
13763                });
13764                let event = event.map(|event| if include_history_context_packs {
13765                    event
13766                } else {
13767                    project_event_without_context_pack(event)
13768                });
13769                let event = event.map(|event| if include_session_task_correlation {
13770                    event
13771                } else {
13772                    project_event_without_session_task_binding(event)
13773                });
13774                let exact_harness_controller = hello.role == ClientRole::Operator
13775                    && shared.controller_state().is_some_and(|controller| {
13776                        controller.connection_id == connection_id
13777                    });
13778                let event = event.and_then(|event| {
13779                    if include_harness_mcp_proxy && exact_harness_controller {
13780                        Some(event)
13781                    } else {
13782                        project_event_without_harness_mcp_proxy(event)
13783                    }
13784                });
13785                if let Some(event) = event {
13786                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13787                }
13788            }
13789        }
13790        tokio::select! {
13791            biased;
13792            frame = frame_rx.recv() => {
13793                let frame = match frame {
13794                    Some(frame) => frame,
13795                    None => break,
13796                };
13797                let frame = match frame {
13798                    Ok(frame) => frame,
13799                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13800                    Err(error) => return Err(error.into()),
13801                };
13802                let ClientFrame::Request(request) = frame else {
13803                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13804                };
13805                #[cfg(feature = "fixture")]
13806                let probe_spawn_managed_worktree_v2 = matches!(
13807                    &request.request,
13808                    NodeRequest::SpawnManagedWorktreeV2 { .. }
13809                );
13810                let requires_open_provider_ids =
13811                    request_requires_open_provider_ids(&shared, &request.request);
13812                let requires_child_environment_profile =
13813                    request_requires_child_environment_profile(&shared, &request.request);
13814                let requires_session_bundle =
13815                    request_requires_session_bundle(&shared, &request.request);
13816                let requires_history_context_pack =
13817                    request_requires_history_context_pack(&shared, &request.request);
13818                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13819                    ResponseEnvelope {
13820                        request_id: request.request_id,
13821                        result: Err(failure(
13822                            NodeFailureCode::UnsupportedCapability,
13823                            "open provider IDs were not negotiated",
13824                        )),
13825                    }
13826                } else if requires_child_environment_profile
13827                    && !include_child_environment_profiles
13828                {
13829                    ResponseEnvelope {
13830                        request_id: request.request_id,
13831                        result: Err(failure(
13832                            NodeFailureCode::UnsupportedCapability,
13833                            "child environment profiles were not negotiated",
13834                        )),
13835                    }
13836                } else if requires_session_bundle && !include_session_bundles {
13837                    ResponseEnvelope {
13838                        request_id: request.request_id,
13839                        result: Err(failure(
13840                            NodeFailureCode::UnsupportedCapability,
13841                            "session bundles were not negotiated",
13842                        )),
13843                    }
13844                } else if requires_history_context_pack && !include_history_context_packs {
13845                    ResponseEnvelope {
13846                        request_id: request.request_id,
13847                        result: Err(failure(
13848                            NodeFailureCode::UnsupportedCapability,
13849                            "history context packs were not negotiated",
13850                        )),
13851                    }
13852                } else if request_uses_unnegotiated_capability(
13853                    &request.request,
13854                    &selected_capabilities,
13855                )
13856                {
13857                    ResponseEnvelope {
13858                        request_id: request.request_id,
13859                        result: Err(failure(
13860                            NodeFailureCode::UnsupportedCapability,
13861                            "request capability was not negotiated",
13862                        )),
13863                    }
13864                } else {
13865                    process_request(&shared, connection_id, hello.role, request).await
13866                };
13867                #[cfg(feature = "fixture")]
13868                if probe_spawn_managed_worktree_v2 {
13869                    shared
13870                        .fixture_spawn_managed_worktree_v2_failure_probe
13871                        .record(reply.result.as_ref().err().map(|failure| failure.code));
13872                }
13873                if !include_provider_runtime_status {
13874                    clear_response_provider_runtime_status(&mut reply);
13875                }
13876                if !include_managed_worktrees {
13877                    project_response_without_managed_worktrees(&mut reply);
13878                }
13879                if !include_open_provider_ids {
13880                    project_response_legacy_provider_ids(&shared, &mut reply);
13881                }
13882                if !include_child_environment_profiles {
13883                    project_response_without_child_environment_profile(&mut reply);
13884                }
13885                if !include_session_bundles {
13886                    project_response_without_session_bundle(&mut reply);
13887                }
13888                project_response_history_for_wire(
13889                    &mut reply,
13890                    include_history_context_packs,
13891                );
13892                if !include_history_context_packs {
13893                    project_response_without_context_pack(&mut reply);
13894                }
13895                if !include_agent_progress {
13896                    project_response_without_agent_progress(&mut reply);
13897                }
13898                if !include_session_task_correlation {
13899                    project_response_without_session_task_binding(&mut reply);
13900                }
13901                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13902            }
13903            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13904            event = event_rx.recv() => {
13905                match event {
13906                    Ok(event) => queue_connection_event(
13907                        &mut pending_events,
13908                        event,
13909                        discard_events_through,
13910                    ),
13911                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13912                    Err(broadcast::error::RecvError::Closed) => break,
13913                }
13914            }
13915            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13916                match events {
13917                    Ok(events) => queue_connection_event_batch(
13918                        &mut pending_events,
13919                        &events,
13920                        discard_events_through,
13921                    ),
13922                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13923                    Err(broadcast::error::RecvError::Closed) => break,
13924                }
13925            }
13926            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13927                match events {
13928                    Ok(events) => queue_connection_event_batch(
13929                        &mut pending_events,
13930                        &events,
13931                        discard_events_through,
13932                    ),
13933                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13934                    Err(broadcast::error::RecvError::Closed) => break,
13935                }
13936            }
13937            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13938                match events {
13939                    Ok(events) => queue_connection_event_batch(
13940                        &mut pending_events,
13941                        &events,
13942                        discard_events_through,
13943                    ),
13944                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13945                    Err(broadcast::error::RecvError::Closed) => break,
13946                }
13947            }
13948            _ = shared.shutdown_notify.notified() => break,
13949        }
13950    }
13951    Ok(())
13952}
13953
13954async fn receive_terminal_event_batch(
13955    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13956) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13957    match receiver {
13958        Some(receiver) => receiver.recv().await,
13959        None => std::future::pending().await,
13960    }
13961}
13962
13963fn terminal_event_subscription(
13964    shared: &NodeShared,
13965    enabled: bool,
13966) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13967    enabled.then(|| shared.terminal_event_tx.subscribe())
13968}
13969
13970/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
13971/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
13972/// this needs a dedicated channel rather than a flag on the terminal one.
13973fn agent_stream_event_subscription(
13974    shared: &NodeShared,
13975    enabled: bool,
13976) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13977    enabled.then(|| shared.agent_stream_event_tx.subscribe())
13978}
13979
13980/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
13981/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
13982/// own doc for why this needs a dedicated channel rather than a ride on
13983/// the durable `event_tx`.
13984fn harness_mcp_proxy_event_subscription(
13985    shared: &NodeShared,
13986    enabled: bool,
13987) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13988    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13989}
13990
13991fn queue_connection_event(
13992    pending: &mut Vec<NodeEventEnvelope>,
13993    event: NodeEventEnvelope,
13994    discard_through: u64,
13995) {
13996    if event.sequence <= discard_through
13997        && !event.event.requires_harness_mcp_proxy_capability()
13998    {
13999        return;
14000    }
14001    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14002        if let Some(index) = pending.iter().position(|current| {
14003            matches!(
14004                &current.event,
14005                NodeEvent::TerminalFrame {
14006                    address: current_address,
14007                    ..
14008                } if current_address == address
14009            )
14010        }) {
14011            pending.remove(index);
14012        }
14013    }
14014    pending.push(event);
14015}
14016
14017fn project_event_without_harness_mcp_proxy(
14018    envelope: NodeEventEnvelope,
14019) -> Option<NodeEventEnvelope> {
14020    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14021}
14022
14023fn queue_connection_event_batch(
14024    pending: &mut Vec<NodeEventEnvelope>,
14025    events: &[NodeEventEnvelope],
14026    discard_through: u64,
14027) {
14028    for event in events {
14029        queue_connection_event(pending, event.clone(), discard_through);
14030    }
14031}
14032
14033fn apply_connection_resync_watermark(
14034    pending: &mut Vec<NodeEventEnvelope>,
14035    discard_through: &mut u64,
14036    marker_sequence: u64,
14037) {
14038    *discard_through = (*discard_through).max(marker_sequence);
14039    pending.retain(|event| event.sequence > *discard_through);
14040}
14041
14042fn connection_event_burst_len(pending_len: usize) -> usize {
14043    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14044}
14045
14046fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14047    let history = shared
14048        .history
14049        .lock()
14050        .unwrap_or_else(|poisoned| poisoned.into_inner());
14051    let sequence = history.last_sequence;
14052    let oldest_available_sequence = history.replay_floor_sequence;
14053    NodeEventEnvelope {
14054        sequence,
14055        event: NodeEvent::ResyncRequired {
14056            oldest_available_sequence,
14057        },
14058    }
14059}
14060
14061fn node_compatibility_support(
14062    shared: &NodeShared,
14063) -> Result<NodeCompatibilitySupport, NodeServerError> {
14064    let mut support = node_compatibility_support_for_manifest(
14065        &shared.provider_contracts,
14066        &shared.provider_adapter_contracts,
14067    )?;
14068    if shared
14069        .delivery_store
14070        .lock()
14071        .unwrap_or_else(|poisoned| poisoned.into_inner())
14072        .is_some()
14073    {
14074        support.capabilities.push(
14075            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14076                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14077        );
14078    }
14079    if shared.harness_mcp_registry.is_some() {
14080        support.capabilities.push(
14081            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14082                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14083        );
14084    }
14085    Ok(support)
14086}
14087
14088fn node_compatibility_support_for_manifest(
14089    provider_contracts: &[ProviderContractSupport],
14090    provider_adapter_contracts: &[ProviderAdapterContractSupport],
14091) -> Result<NodeCompatibilitySupport, NodeServerError> {
14092    Ok(NodeCompatibilitySupport {
14093        build_stamp: BUILD_STAMP.to_owned(),
14094        capabilities: baseline_capabilities()?,
14095        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14096        path_semantics: platform::path_semantics(),
14097        local_transport: platform::local_transport(),
14098        state_schema: StateSchemaSupport {
14099            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14100                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14101        },
14102        provider_contracts: provider_contracts.to_vec(),
14103        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14104    })
14105}
14106
14107fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14108    let capabilities = [
14109        NODE_COMPATIBILITY_METADATA_CAPABILITY,
14110        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14111        NODE_REPOSITORY_PATH_CAPABILITY,
14112        NODE_WORKSPACE_FILE_READ_CAPABILITY,
14113        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14114        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14115        NODE_GIT_READ_CAPABILITY,
14116        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14117        NODE_PROVIDER_ID_OPEN_CAPABILITY,
14118        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14119        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14120        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14121        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14122        NODE_ACP_CONTROL_CAPABILITY,
14123        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14124        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14125        NODE_WORKTREE_SELECTION_CAPABILITY,
14126        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14127        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14128        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14129        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14130        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14131        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14132        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14133        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14134        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14135        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14136        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14137        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14138    ]
14139        .into_iter()
14140        .map(|capability| {
14141            CapabilityId::new(capability)
14142                .map_err(|error| NodeServerError::Handshake(error.to_string()))
14143        })
14144        .collect::<Result<Vec<_>, _>>()?;
14145    #[cfg(windows)]
14146    let capabilities = {
14147        let mut capabilities = capabilities;
14148        capabilities.push(
14149            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14150                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14151        );
14152        capabilities
14153    };
14154    Ok(capabilities)
14155}
14156
14157fn request_uses_unnegotiated_capability(
14158    request: &NodeRequest,
14159    selected_capabilities: &[CapabilityId],
14160) -> bool {
14161    let selected = |required: &str| {
14162        selected_capabilities
14163            .iter()
14164            .any(|capability| capability.as_str() == required)
14165    };
14166    request
14167        .required_capability()
14168        .is_some_and(|required| !selected(required))
14169        || (request.requires_worktree_selection_capability()
14170            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14171        || (request.requires_spawn_spec_defaults_overrides_capability()
14172            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14173        || (request.requires_spawn_profile_revision_capability()
14174            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14175        || (request.requires_child_environment_profile_capability()
14176            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14177        || (request.requires_session_bundle_materialization_capability()
14178            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14179        || (request.requires_history_context_pack_capability()
14180            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14181}
14182
14183fn snapshot_for_wire(
14184    shared: &NodeShared,
14185    include_provider_runtime_status: bool,
14186    include_open_provider_ids: bool,
14187    include_spawn_profiles: bool,
14188    include_managed_worktrees: bool,
14189    include_child_environment_profiles: bool,
14190    include_session_bundles: bool,
14191    include_history_context_packs: bool,
14192    include_agent_progress: bool,
14193    include_session_task_correlation: bool,
14194) -> NodeSnapshot {
14195    let mut snapshot = shared.snapshot();
14196    if !include_provider_runtime_status {
14197        snapshot.provider_runtime_statuses.clear();
14198    }
14199    if !include_open_provider_ids {
14200        project_snapshot_legacy_provider_ids(&mut snapshot);
14201    }
14202    if !include_managed_worktrees {
14203        snapshot.managed_worktrees.clear();
14204        for workspace in &mut snapshot.workspaces {
14205            workspace.managed_worktree_profiles = None;
14206        }
14207    }
14208    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14209        if !include_spawn_profiles {
14210            inventory.spawn_profiles = None;
14211        }
14212        if !include_session_bundles {
14213            inventory.bundles = None;
14214        }
14215        if inventory.spawn_profiles.is_none()
14216            && inventory.bundles.is_none()
14217            && inventory.network_allowlists.is_none()
14218        {
14219            snapshot.launch_inventory = None;
14220        }
14221    }
14222    if !include_child_environment_profiles {
14223        clear_snapshot_child_environment_profiles(&mut snapshot);
14224    }
14225    if !include_session_bundles {
14226        clear_snapshot_session_bundles(&mut snapshot);
14227    }
14228    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14229    if !include_history_context_packs {
14230        clear_snapshot_context_packs(&mut snapshot);
14231    }
14232    if !include_agent_progress {
14233        snapshot.agent_progress.clear();
14234    }
14235    if !include_session_task_correlation {
14236        clear_snapshot_session_task_bindings(&mut snapshot);
14237    }
14238    snapshot
14239}
14240
14241fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14242    for record in &mut snapshot.session_records {
14243        record.task_binding = None;
14244    }
14245}
14246
14247fn project_event_without_session_task_binding(
14248    mut envelope: NodeEventEnvelope,
14249) -> NodeEventEnvelope {
14250    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14251        record.task_binding = None;
14252    }
14253    envelope
14254}
14255
14256fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14257    let Ok(response) = reply.result.as_mut() else { return };
14258    match response {
14259        NodeResponse::Snapshot { snapshot, .. } => {
14260            clear_snapshot_session_task_bindings(snapshot);
14261        }
14262        NodeResponse::Resync { snapshot, events, .. } => {
14263            clear_snapshot_session_task_bindings(snapshot);
14264            for event in events {
14265                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14266                    record.task_binding = None;
14267                }
14268            }
14269        }
14270        NodeResponse::SessionRecordUpdated { record }
14271        | NodeResponse::ProviderSessionIndexed { record }
14272        | NodeResponse::NativeSessionIndexed { record, .. }
14273        | NodeResponse::SessionRecordResumed { record, .. } => {
14274            record.task_binding = None;
14275        }
14276        _ => {}
14277    }
14278}
14279
14280fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14281    let Ok(response) = reply.result.as_mut() else { return };
14282    match response {
14283        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14284            snapshot.agent_progress.clear();
14285        }
14286        _ => {}
14287    }
14288}
14289
14290fn project_event_without_managed_worktrees(
14291    mut envelope: NodeEventEnvelope,
14292) -> Option<NodeEventEnvelope> {
14293    if matches!(
14294        envelope.event,
14295        NodeEvent::ManagedWorktreeUpserted { .. }
14296            | NodeEvent::ManagedWorktreeRemoved { .. }
14297    ) {
14298        return None;
14299    }
14300    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14301        workspace.managed_worktree_profiles = None;
14302    }
14303    Some(envelope)
14304}
14305
14306fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14307    if matches!(
14308        reply.result,
14309        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14310            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14311    ) {
14312        reply.result = Err(failure(
14313            NodeFailureCode::UnsupportedCapability,
14314            "managed worktree capability was not negotiated",
14315        ));
14316        return;
14317    }
14318    let Ok(response) = reply.result.as_mut() else { return };
14319    match response {
14320        NodeResponse::Snapshot { snapshot, .. } => {
14321            snapshot.managed_worktrees.clear();
14322            for workspace in &mut snapshot.workspaces {
14323                workspace.managed_worktree_profiles = None;
14324            }
14325        }
14326        NodeResponse::Resync { snapshot, events, .. } => {
14327            snapshot.managed_worktrees.clear();
14328            for workspace in &mut snapshot.workspaces {
14329                workspace.managed_worktree_profiles = None;
14330            }
14331            *events = std::mem::take(events)
14332                .into_iter()
14333                .filter_map(project_event_without_managed_worktrees)
14334                .collect();
14335        }
14336        NodeResponse::WorkspaceRegistered { workspace }
14337        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14338        | NodeResponse::WorktreeCreated { workspace, .. } => {
14339            workspace.managed_worktree_profiles = None;
14340        }
14341        NodeResponse::WorkspaceInspected { inspection } => {
14342            inspection.git.managed_worktree = None;
14343        }
14344        _ => {}
14345    }
14346}
14347
14348fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14349    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14350        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14351    });
14352    if includes_open_provider_ids {
14353        return;
14354    }
14355    compatibility
14356        .provider_contracts
14357        .retain(|contract| provider_id_is_legacy(&contract.provider));
14358    compatibility
14359        .provider_adapter_contracts
14360        .retain(|contract| provider_id_is_legacy(&contract.provider));
14361}
14362
14363fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14364    if let Some(spec) = match request {
14365        NodeRequest::SpawnSpec { spec }
14366        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14367        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14368        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14369        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14370        _ => None,
14371    } {
14372        return shared
14373            .resolve_spawn_spec(spec)
14374            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14375    }
14376    request_requires_open_provider_ids_with(
14377        request,
14378        |session| shared.validate_address(session).ok(),
14379        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14380    )
14381}
14382
14383fn request_requires_child_environment_profile(
14384    shared: &NodeShared,
14385    request: &NodeRequest,
14386) -> bool {
14387    if let Some(spec) = match request {
14388        NodeRequest::SpawnSpec { spec }
14389        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14390        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14391        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14392        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14393        _ => None,
14394    } {
14395        return shared
14396            .resolve_spawn_spec(spec)
14397            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14398    }
14399    match request {
14400        NodeRequest::Resume { session, .. }
14401        | NodeRequest::Prompt { session, .. }
14402        | NodeRequest::Paste { session, .. }
14403        | NodeRequest::Input { session, .. }
14404        | NodeRequest::TerminalBytes { session, .. }
14405        | NodeRequest::TerminalControl { session, .. }
14406        | NodeRequest::Resize { session, .. }
14407        | NodeRequest::Interrupt { session }
14408        | NodeRequest::Stop { session, .. }
14409        | NodeRequest::Remove { session }
14410        | NodeRequest::ResolveInteraction { session, .. }
14411        | NodeRequest::SetSessionMode { session, .. }
14412        | NodeRequest::SetSessionConfigOption { session, .. }
14413        | NodeRequest::SetSessionModel { session, .. }
14414        | NodeRequest::DiscoverHistory { session, .. }
14415        | NodeRequest::LoadHistory { session, .. }
14416        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14417        | NodeRequest::ExportContextPack { session } => shared
14418            .session_bindings
14419            .lock()
14420            .unwrap_or_else(|poisoned| poisoned.into_inner())
14421            .get(&session.session.instance_id)
14422            .filter(|binding| {
14423                binding.workspace_id == session.workspace_id
14424                    && binding.generation == session.session.generation
14425            })
14426            .map_or(true, |binding| binding.environment_profile.is_some()),
14427        NodeRequest::RenameSessionRecord { record_id, .. }
14428        | NodeRequest::SetSessionTask { record_id, .. }
14429        | NodeRequest::ResumeSessionRecord { record_id, .. }
14430        | NodeRequest::ForgetSessionRecord { record_id } => shared
14431            .record(record_id)
14432            .map_or(true, |record| record.environment_profile.is_some()),
14433        NodeRequest::ArmHarnessMcpReservation { .. }
14434        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14435        | NodeRequest::ActivateHarnessMcpReservation { .. }
14436        | NodeRequest::AbortHarnessMcpReservation { .. }
14437        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14438        | NodeRequest::RejectHarnessMcpCall { .. }
14439        | NodeRequest::Snapshot
14440        | NodeRequest::Resync { .. }
14441        | NodeRequest::BeginDeliveryStage { .. }
14442        | NodeRequest::PutDeliveryBlobChunk { .. }
14443        | NodeRequest::CommitDeliveryStage { .. }
14444        | NodeRequest::AbortDeliveryStage { .. }
14445        | NodeRequest::BrowseHostDirectories { .. }
14446        | NodeRequest::InspectWorkspace { .. }
14447        | NodeRequest::ReadWorkspaceFile { .. }
14448        | NodeRequest::WriteWorkspaceFile { .. }
14449        | NodeRequest::CreateWorkspaceFile { .. }
14450        | NodeRequest::CreateWorkspaceDirectory { .. }
14451        | NodeRequest::ReadGitHistory { .. }
14452        | NodeRequest::ReadGitDiff { .. }
14453        | NodeRequest::AcquireController { .. }
14454        | NodeRequest::ReleaseController
14455        | NodeRequest::Spawn { .. }
14456        | NodeRequest::SpawnWithOptions { .. }
14457        | NodeRequest::SpawnSpec { .. }
14458        | NodeRequest::RegisterWorkspace { .. }
14459        | NodeRequest::CreateStandaloneWorkspace { .. }
14460        | NodeRequest::UnregisterWorkspace { .. }
14461        | NodeRequest::CreateWorktree { .. }
14462        | NodeRequest::RemoveWorktree { .. }
14463        | NodeRequest::SpawnManagedWorktree { .. }
14464        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14465        | NodeRequest::CleanupManagedWorktree { .. }
14466        | NodeRequest::IndexProviderSession { .. }
14467        | NodeRequest::IndexNativeSession { .. }
14468        | NodeRequest::CatalogNativeSessions { .. }
14469        | NodeRequest::PageNativeSessions { .. }
14470        | NodeRequest::PreviewNativeSession { .. }
14471        | NodeRequest::PreviewSessionRecord { .. }
14472        | NodeRequest::ForgetContextPack { .. }
14473        | NodeRequest::ResolveDurableContextPack { .. }
14474        | NodeRequest::ReadContextPack { .. }
14475        | NodeRequest::Shutdown => false,
14476    }
14477}
14478
14479fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14480    if let Some(spec) = match request {
14481        NodeRequest::SpawnSpec { spec }
14482        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14483        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14484        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14485        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14486        _ => None,
14487    } {
14488        return shared
14489            .resolve_spawn_spec(spec)
14490            .is_ok_and(|resolved| resolved.bundle_id.is_some());
14491    }
14492    match request {
14493        NodeRequest::Resume { session, .. }
14494        | NodeRequest::Prompt { session, .. }
14495        | NodeRequest::Paste { session, .. }
14496        | NodeRequest::Input { session, .. }
14497        | NodeRequest::TerminalBytes { session, .. }
14498        | NodeRequest::TerminalControl { session, .. }
14499        | NodeRequest::Resize { session, .. }
14500        | NodeRequest::Interrupt { session }
14501        | NodeRequest::Stop { session, .. }
14502        | NodeRequest::Remove { session }
14503        | NodeRequest::ResolveInteraction { session, .. }
14504        | NodeRequest::SetSessionMode { session, .. }
14505        | NodeRequest::SetSessionConfigOption { session, .. }
14506        | NodeRequest::SetSessionModel { session, .. }
14507        | NodeRequest::DiscoverHistory { session, .. }
14508        | NodeRequest::LoadHistory { session, .. }
14509        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14510        | NodeRequest::ExportContextPack { session } => shared
14511            .session_bindings
14512            .lock()
14513            .unwrap_or_else(|poisoned| poisoned.into_inner())
14514            .get(&session.session.instance_id)
14515            .filter(|binding| {
14516                binding.workspace_id == session.workspace_id
14517                    && binding.generation == session.session.generation
14518            })
14519            .map_or(true, |binding| binding.bundle.is_some()),
14520        NodeRequest::RenameSessionRecord { record_id, .. }
14521        | NodeRequest::SetSessionTask { record_id, .. }
14522        | NodeRequest::ResumeSessionRecord { record_id, .. }
14523        | NodeRequest::ForgetSessionRecord { record_id } => shared
14524            .record(record_id)
14525            .map_or(true, |record| record.bundle.is_some()),
14526        NodeRequest::ArmHarnessMcpReservation { .. }
14527        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14528        | NodeRequest::ActivateHarnessMcpReservation { .. }
14529        | NodeRequest::AbortHarnessMcpReservation { .. }
14530        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14531        | NodeRequest::RejectHarnessMcpCall { .. }
14532        | NodeRequest::Snapshot
14533        | NodeRequest::Resync { .. }
14534        | NodeRequest::BeginDeliveryStage { .. }
14535        | NodeRequest::PutDeliveryBlobChunk { .. }
14536        | NodeRequest::CommitDeliveryStage { .. }
14537        | NodeRequest::AbortDeliveryStage { .. }
14538        | NodeRequest::BrowseHostDirectories { .. }
14539        | NodeRequest::InspectWorkspace { .. }
14540        | NodeRequest::ReadWorkspaceFile { .. }
14541        | NodeRequest::WriteWorkspaceFile { .. }
14542        | NodeRequest::CreateWorkspaceFile { .. }
14543        | NodeRequest::CreateWorkspaceDirectory { .. }
14544        | NodeRequest::ReadGitHistory { .. }
14545        | NodeRequest::ReadGitDiff { .. }
14546        | NodeRequest::AcquireController { .. }
14547        | NodeRequest::ReleaseController
14548        | NodeRequest::Spawn { .. }
14549        | NodeRequest::SpawnWithOptions { .. }
14550        | NodeRequest::SpawnSpec { .. }
14551        | NodeRequest::RegisterWorkspace { .. }
14552        | NodeRequest::CreateStandaloneWorkspace { .. }
14553        | NodeRequest::UnregisterWorkspace { .. }
14554        | NodeRequest::CreateWorktree { .. }
14555        | NodeRequest::RemoveWorktree { .. }
14556        | NodeRequest::SpawnManagedWorktree { .. }
14557        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14558        | NodeRequest::CleanupManagedWorktree { .. }
14559        | NodeRequest::IndexProviderSession { .. }
14560        | NodeRequest::IndexNativeSession { .. }
14561        | NodeRequest::CatalogNativeSessions { .. }
14562        | NodeRequest::PageNativeSessions { .. }
14563        | NodeRequest::PreviewNativeSession { .. }
14564        | NodeRequest::PreviewSessionRecord { .. }
14565        | NodeRequest::ForgetContextPack { .. }
14566        | NodeRequest::ResolveDurableContextPack { .. }
14567        | NodeRequest::ReadContextPack { .. }
14568        | NodeRequest::Shutdown => false,
14569    }
14570}
14571
14572fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14573    if request.requires_history_context_pack_capability() {
14574        return true;
14575    }
14576    if let Some(spec) = match request {
14577        NodeRequest::SpawnSpec { spec }
14578        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14579        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14580        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14581        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14582        _ => None,
14583    } {
14584        return shared
14585            .resolve_spawn_spec(spec)
14586            .map_or(true, |resolved| resolved.context_id.is_some());
14587    }
14588    match request {
14589        NodeRequest::Resume { session, .. }
14590        | NodeRequest::Prompt { session, .. }
14591        | NodeRequest::Paste { session, .. }
14592        | NodeRequest::Input { session, .. }
14593        | NodeRequest::TerminalBytes { session, .. }
14594        | NodeRequest::TerminalControl { session, .. }
14595        | NodeRequest::Resize { session, .. }
14596        | NodeRequest::Interrupt { session }
14597        | NodeRequest::Stop { session, .. }
14598        | NodeRequest::Remove { session }
14599        | NodeRequest::ResolveInteraction { session, .. }
14600        | NodeRequest::SetSessionMode { session, .. }
14601        | NodeRequest::SetSessionConfigOption { session, .. }
14602        | NodeRequest::SetSessionModel { session, .. } => shared
14603            .session_bindings
14604            .lock()
14605            .unwrap_or_else(|poisoned| poisoned.into_inner())
14606            .get(&session.session.instance_id)
14607            .filter(|binding| {
14608                binding.workspace_id == session.workspace_id
14609                    && binding.generation == session.session.generation
14610            })
14611            .map_or(true, |binding| binding.context.is_some()),
14612        NodeRequest::RenameSessionRecord { record_id, .. }
14613        | NodeRequest::SetSessionTask { record_id, .. }
14614        | NodeRequest::ResumeSessionRecord { record_id, .. }
14615        | NodeRequest::ForgetSessionRecord { record_id } => shared
14616            .record(record_id)
14617            .map_or(true, |record| record.context.is_some()),
14618        NodeRequest::ArmHarnessMcpReservation { .. }
14619        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14620        | NodeRequest::ActivateHarnessMcpReservation { .. }
14621        | NodeRequest::AbortHarnessMcpReservation { .. }
14622        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14623        | NodeRequest::RejectHarnessMcpCall { .. }
14624        | NodeRequest::Snapshot
14625        | NodeRequest::Resync { .. }
14626        | NodeRequest::BeginDeliveryStage { .. }
14627        | NodeRequest::PutDeliveryBlobChunk { .. }
14628        | NodeRequest::CommitDeliveryStage { .. }
14629        | NodeRequest::AbortDeliveryStage { .. }
14630        | NodeRequest::BrowseHostDirectories { .. }
14631        | NodeRequest::InspectWorkspace { .. }
14632        | NodeRequest::ReadWorkspaceFile { .. }
14633        | NodeRequest::WriteWorkspaceFile { .. }
14634        | NodeRequest::CreateWorkspaceFile { .. }
14635        | NodeRequest::CreateWorkspaceDirectory { .. }
14636        | NodeRequest::ReadGitHistory { .. }
14637        | NodeRequest::ReadGitDiff { .. }
14638        | NodeRequest::AcquireController { .. }
14639        | NodeRequest::ReleaseController
14640        | NodeRequest::Spawn { .. }
14641        | NodeRequest::SpawnWithOptions { .. }
14642        | NodeRequest::SpawnSpec { .. }
14643        | NodeRequest::RegisterWorkspace { .. }
14644        | NodeRequest::CreateStandaloneWorkspace { .. }
14645        | NodeRequest::UnregisterWorkspace { .. }
14646        | NodeRequest::CreateWorktree { .. }
14647        | NodeRequest::RemoveWorktree { .. }
14648        | NodeRequest::SpawnManagedWorktree { .. }
14649        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14650        | NodeRequest::CleanupManagedWorktree { .. }
14651        | NodeRequest::IndexProviderSession { .. }
14652        | NodeRequest::IndexNativeSession { .. }
14653        | NodeRequest::CatalogNativeSessions { .. }
14654        | NodeRequest::PageNativeSessions { .. }
14655        | NodeRequest::PreviewNativeSession { .. }
14656        | NodeRequest::PreviewSessionRecord { .. }
14657        | NodeRequest::DiscoverHistory { .. }
14658        | NodeRequest::LoadHistory { .. }
14659        | NodeRequest::ExportContextPackForSessionRecord { .. }
14660        | NodeRequest::ExportContextPack { .. }
14661        | NodeRequest::ForgetContextPack { .. }
14662        | NodeRequest::ResolveDurableContextPack { .. }
14663        | NodeRequest::ReadContextPack { .. }
14664        | NodeRequest::Shutdown => false,
14665    }
14666}
14667
14668fn request_requires_open_provider_ids_with(
14669    request: &NodeRequest,
14670    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14671    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14672) -> bool {
14673    match request {
14674        NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
14675        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14676        NodeRequest::CatalogNativeSessions { route, .. }
14677        | NodeRequest::PageNativeSessions { route, .. } => {
14678            !provider_id_is_legacy(&route.provider)
14679        }
14680        NodeRequest::PreviewNativeSession { selection, .. }
14681        | NodeRequest::IndexNativeSession { selection, .. } => {
14682            !provider_id_is_legacy(&selection.route.provider)
14683        }
14684        NodeRequest::SpawnSpec { .. }
14685        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14686        NodeRequest::SpawnManagedWorktree { .. }
14687        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14688        NodeRequest::Resume { session, .. }
14689        | NodeRequest::Prompt { session, .. }
14690        | NodeRequest::Paste { session, .. }
14691        | NodeRequest::Input { session, .. }
14692        | NodeRequest::TerminalBytes { session, .. }
14693        | NodeRequest::TerminalControl { session, .. }
14694        | NodeRequest::Resize { session, .. }
14695        | NodeRequest::Interrupt { session }
14696        | NodeRequest::Stop { session, .. }
14697        | NodeRequest::Remove { session }
14698        | NodeRequest::ResolveInteraction { session, .. }
14699        | NodeRequest::SetSessionMode { session, .. }
14700        | NodeRequest::SetSessionConfigOption { session, .. }
14701        | NodeRequest::SetSessionModel { session, .. }
14702        | NodeRequest::DiscoverHistory { session, .. }
14703        | NodeRequest::LoadHistory { session, .. }
14704        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14705        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14706            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14707        NodeRequest::RenameSessionRecord { record_id, .. }
14708        | NodeRequest::SetSessionTask { record_id, .. }
14709        | NodeRequest::ResumeSessionRecord { record_id, .. }
14710        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14711            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14712        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14713            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14714        NodeRequest::ArmHarnessMcpReservation { .. }
14715        | NodeRequest::ActivateHarnessMcpReservation { .. }
14716        | NodeRequest::AbortHarnessMcpReservation { .. }
14717        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14718        | NodeRequest::RejectHarnessMcpCall { .. }
14719        | NodeRequest::Snapshot
14720        | NodeRequest::Resync { .. }
14721        | NodeRequest::BeginDeliveryStage { .. }
14722        | NodeRequest::PutDeliveryBlobChunk { .. }
14723        | NodeRequest::CommitDeliveryStage { .. }
14724        | NodeRequest::AbortDeliveryStage { .. }
14725        | NodeRequest::BrowseHostDirectories { .. }
14726        | NodeRequest::InspectWorkspace { .. }
14727        | NodeRequest::ReadWorkspaceFile { .. }
14728        | NodeRequest::WriteWorkspaceFile { .. }
14729        | NodeRequest::CreateWorkspaceFile { .. }
14730        | NodeRequest::CreateWorkspaceDirectory { .. }
14731        | NodeRequest::ReadGitHistory { .. }
14732        | NodeRequest::ReadGitDiff { .. }
14733        | NodeRequest::AcquireController { .. }
14734        | NodeRequest::ReleaseController
14735        | NodeRequest::RegisterWorkspace { .. }
14736        | NodeRequest::CreateStandaloneWorkspace { .. }
14737        | NodeRequest::UnregisterWorkspace { .. }
14738        | NodeRequest::CreateWorktree { .. }
14739        | NodeRequest::RemoveWorktree { .. }
14740        | NodeRequest::CleanupManagedWorktree { .. }
14741        | NodeRequest::Shutdown => false,
14742        NodeRequest::ForgetContextPack { .. } => true,
14743        // No session/record to cheaply resolve a provider from here either;
14744        // the resolved receipt can carry an open-id provider, so require the
14745        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
14746        // is the same digest-keyed lookup one step further into the same
14747        // catalog, so it inherits the same conservative answer.
14748        NodeRequest::ResolveDurableContextPack { .. }
14749        | NodeRequest::ReadContextPack { .. } => true,
14750    }
14751}
14752
14753fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14754    snapshot.enabled_providers.retain(provider_id_is_legacy);
14755    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14756        snapshot
14757            .provider_runtime_statuses
14758            .as_slice()
14759            .iter()
14760            .filter(|status| provider_id_is_legacy(status.provider()))
14761            .cloned(),
14762    )
14763    .expect("a filtered provider runtime projection remains valid");
14764    snapshot
14765        .session_records
14766        .retain(|record| provider_id_is_legacy(&record.provider));
14767    for workspace in &mut snapshot.workspaces {
14768        workspace
14769            .sessions
14770            .retain(|session| provider_id_is_legacy(&session.agent_id));
14771    }
14772}
14773
14774fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14775    for record in &mut snapshot.session_records {
14776        record.environment_profile = None;
14777    }
14778    if let Some(profiles) = snapshot
14779        .launch_inventory
14780        .as_mut()
14781        .and_then(|inventory| inventory.spawn_profiles.as_mut())
14782    {
14783        for profile in profiles {
14784            profile.environment_profile = None;
14785        }
14786    }
14787}
14788
14789fn project_event_without_child_environment_profile(
14790    mut envelope: NodeEventEnvelope,
14791) -> NodeEventEnvelope {
14792    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14793        record.environment_profile = None;
14794    }
14795    envelope
14796}
14797
14798fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14799    let contains_environment_profile = match reply.result.as_ref() {
14800        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14801            receipt.environment_profile.is_some()
14802        }
14803        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14804            receipt.spawn.environment_profile.is_some()
14805        }
14806        _ => false,
14807    };
14808    if contains_environment_profile {
14809        reply.result = Err(failure(
14810            NodeFailureCode::UnsupportedCapability,
14811            "child environment profiles were not negotiated",
14812        ));
14813        return;
14814    }
14815    let Ok(response) = reply.result.as_mut() else {
14816        return;
14817    };
14818    match response {
14819        NodeResponse::Snapshot { snapshot, .. } => {
14820            clear_snapshot_child_environment_profiles(snapshot);
14821        }
14822        NodeResponse::Resync { snapshot, events, .. } => {
14823            clear_snapshot_child_environment_profiles(snapshot);
14824            for event in events {
14825                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14826                    record.environment_profile = None;
14827                }
14828            }
14829        }
14830        NodeResponse::SessionRecordUpdated { record }
14831        | NodeResponse::ProviderSessionIndexed { record }
14832        | NodeResponse::NativeSessionIndexed { record, .. }
14833        | NodeResponse::SessionRecordResumed { record, .. } => {
14834            record.environment_profile = None;
14835        }
14836        NodeResponse::Armed { .. }
14837        | NodeResponse::Spawned { .. }
14838        | NodeResponse::Activated { .. }
14839        | NodeResponse::Aborted { .. }
14840        | NodeResponse::ReplyChunkAccepted { .. }
14841        | NodeResponse::CallRejected { .. }
14842        | NodeResponse::DeliveryStageBegun { .. }
14843        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14844        | NodeResponse::DeliveryCommitted { .. }
14845        | NodeResponse::DeliveryStageAborted { .. }
14846        | NodeResponse::WorkspaceInspected { .. }
14847        | NodeResponse::HostDirectoriesBrowsed { .. }
14848        | NodeResponse::WorkspaceFileRead { .. }
14849        | NodeResponse::WorkspaceFileWritten { .. }
14850        | NodeResponse::WorkspaceFileCreated { .. }
14851        | NodeResponse::WorkspaceDirectoryCreated { .. }
14852        | NodeResponse::GitHistoryRead { .. }
14853        | NodeResponse::GitDiffRead { .. }
14854        | NodeResponse::Controller { .. }
14855        | NodeResponse::SpawnAccepted { .. }
14856        | NodeResponse::SpawnSpecAccepted { .. }
14857        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14858        | NodeResponse::ManagedWorktreeCleanup { .. }
14859        | NodeResponse::NativeSessionsCataloged { .. }
14860        | NodeResponse::NativeSessionsPaged { .. }
14861        | NodeResponse::NativeSessionPreviewed { .. }
14862        | NodeResponse::SessionRecordPreviewed { .. }
14863        | NodeResponse::HistoryDiscovered { .. }
14864        | NodeResponse::HistoryLoaded { .. }
14865        | NodeResponse::ContextPackForSessionRecordExported { .. }
14866        | NodeResponse::ContextPackExported { .. }
14867        | NodeResponse::ContextPackForgotten { .. }
14868        | NodeResponse::DurableContextPackResolved { .. }
14869        | NodeResponse::ContextPackBytesRead { .. }
14870        | NodeResponse::SessionRecordForgotten { .. }
14871        | NodeResponse::WorkspaceRegistered { .. }
14872        | NodeResponse::StandaloneWorkspaceCreated { .. }
14873        | NodeResponse::WorkspaceUnregistered { .. }
14874        | NodeResponse::WorktreeCreated { .. }
14875        | NodeResponse::WorktreeRemoved { .. }
14876        | NodeResponse::Accepted
14877        | NodeResponse::ShuttingDown => {}
14878    }
14879}
14880
14881fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14882    for record in &mut snapshot.session_records {
14883        record.bundle = None;
14884    }
14885}
14886
14887fn project_event_without_session_bundle(
14888    mut envelope: NodeEventEnvelope,
14889) -> NodeEventEnvelope {
14890    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14891        record.bundle = None;
14892    }
14893    envelope
14894}
14895
14896fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14897    let contains_bundle = match reply.result.as_ref() {
14898        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14899        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14900            receipt.spawn.bundle.is_some()
14901        }
14902        _ => false,
14903    };
14904    if contains_bundle {
14905        reply.result = Err(failure(
14906            NodeFailureCode::UnsupportedCapability,
14907            "session bundles were not negotiated",
14908        ));
14909        return;
14910    }
14911    let Ok(response) = reply.result.as_mut() else {
14912        return;
14913    };
14914    match response {
14915        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14916        NodeResponse::Resync { snapshot, events, .. } => {
14917            clear_snapshot_session_bundles(snapshot);
14918            for event in events {
14919                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14920                    record.bundle = None;
14921                }
14922            }
14923        }
14924        NodeResponse::SessionRecordUpdated { record }
14925        | NodeResponse::ProviderSessionIndexed { record }
14926        | NodeResponse::NativeSessionIndexed { record, .. }
14927        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14928        NodeResponse::Armed { .. }
14929        | NodeResponse::Spawned { .. }
14930        | NodeResponse::Activated { .. }
14931        | NodeResponse::Aborted { .. }
14932        | NodeResponse::ReplyChunkAccepted { .. }
14933        | NodeResponse::CallRejected { .. }
14934        | NodeResponse::DeliveryStageBegun { .. }
14935        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14936        | NodeResponse::DeliveryCommitted { .. }
14937        | NodeResponse::DeliveryStageAborted { .. }
14938        | NodeResponse::WorkspaceInspected { .. }
14939        | NodeResponse::HostDirectoriesBrowsed { .. }
14940        | NodeResponse::WorkspaceFileRead { .. }
14941        | NodeResponse::WorkspaceFileWritten { .. }
14942        | NodeResponse::WorkspaceFileCreated { .. }
14943        | NodeResponse::WorkspaceDirectoryCreated { .. }
14944        | NodeResponse::GitHistoryRead { .. }
14945        | NodeResponse::GitDiffRead { .. }
14946        | NodeResponse::Controller { .. }
14947        | NodeResponse::SpawnAccepted { .. }
14948        | NodeResponse::SpawnSpecAccepted { .. }
14949        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14950        | NodeResponse::ManagedWorktreeCleanup { .. }
14951        | NodeResponse::NativeSessionsCataloged { .. }
14952        | NodeResponse::NativeSessionsPaged { .. }
14953        | NodeResponse::NativeSessionPreviewed { .. }
14954        | NodeResponse::SessionRecordPreviewed { .. }
14955        | NodeResponse::HistoryDiscovered { .. }
14956        | NodeResponse::HistoryLoaded { .. }
14957        | NodeResponse::ContextPackForSessionRecordExported { .. }
14958        | NodeResponse::ContextPackExported { .. }
14959        | NodeResponse::ContextPackForgotten { .. }
14960        | NodeResponse::DurableContextPackResolved { .. }
14961        | NodeResponse::ContextPackBytesRead { .. }
14962        | NodeResponse::SessionRecordForgotten { .. }
14963        | NodeResponse::WorkspaceRegistered { .. }
14964        | NodeResponse::StandaloneWorkspaceCreated { .. }
14965        | NodeResponse::WorkspaceUnregistered { .. }
14966        | NodeResponse::WorktreeCreated { .. }
14967        | NodeResponse::WorktreeRemoved { .. }
14968        | NodeResponse::Accepted
14969        | NodeResponse::ShuttingDown => {}
14970    }
14971}
14972
14973fn project_snapshot_history_for_wire(
14974    snapshot: &mut NodeSnapshot,
14975    include_history_metadata: bool,
14976) {
14977    for workspace in &mut snapshot.workspaces {
14978        for session in &mut workspace.sessions {
14979            if include_history_metadata {
14980                session.history.loaded = None;
14981            } else {
14982                session.history = Default::default();
14983            }
14984        }
14985    }
14986}
14987
14988fn project_event_history_for_wire(
14989    mut envelope: NodeEventEnvelope,
14990    include_history_metadata: bool,
14991) -> NodeEventEnvelope {
14992    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14993        for session in &mut workspace.sessions {
14994            if include_history_metadata {
14995                session.history.loaded = None;
14996            } else {
14997                session.history = Default::default();
14998            }
14999        }
15000    }
15001    envelope
15002}
15003
15004fn project_response_history_for_wire(
15005    reply: &mut ResponseEnvelope,
15006    include_history_metadata: bool,
15007) {
15008    let Ok(response) = reply.result.as_mut() else {
15009        return;
15010    };
15011    match response {
15012        NodeResponse::Snapshot { snapshot, .. } => {
15013            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15014        }
15015        NodeResponse::Resync {
15016            snapshot, events, ..
15017        } => {
15018            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15019            for event in events {
15020                *event = project_event_history_for_wire(
15021                    event.clone(),
15022                    include_history_metadata,
15023                );
15024            }
15025        }
15026        NodeResponse::WorkspaceRegistered { workspace }
15027        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15028        | NodeResponse::WorktreeCreated { workspace, .. } => {
15029            for session in &mut workspace.sessions {
15030                if include_history_metadata {
15031                    session.history.loaded = None;
15032                } else {
15033                    session.history = Default::default();
15034                }
15035            }
15036        }
15037        _ => {}
15038    }
15039}
15040
15041fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15042    for record in &mut snapshot.session_records {
15043        record.context_id = None;
15044        record.context = None;
15045        record.exported_context = None;
15046    }
15047}
15048
15049fn project_event_without_context_pack(
15050    mut envelope: NodeEventEnvelope,
15051) -> NodeEventEnvelope {
15052    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15053        record.context_id = None;
15054        record.context = None;
15055        record.exported_context = None;
15056    }
15057    envelope
15058}
15059
15060fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15061    let contains_context = match reply.result.as_ref() {
15062        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15063        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15064            receipt.spawn.context.is_some()
15065        }
15066        Ok(NodeResponse::HistoryDiscovered { .. })
15067        | Ok(NodeResponse::HistoryLoaded { .. })
15068        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15069        | Ok(NodeResponse::ContextPackExported { .. })
15070        | Ok(NodeResponse::ContextPackForgotten { .. })
15071        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15072        _ => false,
15073    };
15074    if contains_context {
15075        reply.result = Err(failure(
15076            NodeFailureCode::UnsupportedCapability,
15077            "history context packs were not negotiated",
15078        ));
15079        return;
15080    }
15081    let Ok(response) = reply.result.as_mut() else {
15082        return;
15083    };
15084    match response {
15085        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15086        NodeResponse::Resync {
15087            snapshot, events, ..
15088        } => {
15089            clear_snapshot_context_packs(snapshot);
15090            for event in events {
15091                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15092                    record.context_id = None;
15093                    record.context = None;
15094                    record.exported_context = None;
15095                }
15096            }
15097        }
15098        NodeResponse::SessionRecordUpdated { record }
15099        | NodeResponse::ProviderSessionIndexed { record }
15100        | NodeResponse::NativeSessionIndexed { record, .. }
15101        | NodeResponse::SessionRecordResumed { record, .. } => {
15102            record.context_id = None;
15103            record.context = None;
15104            record.exported_context = None;
15105        }
15106        _ => {}
15107    }
15108}
15109
15110fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15111    let contains_open_record = match reply.result.as_ref() {
15112        Ok(NodeResponse::SessionRecordUpdated { record })
15113        | Ok(NodeResponse::ProviderSessionIndexed { record })
15114        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15115            !provider_id_is_legacy(&record.provider)
15116        }
15117        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15118            !provider_id_is_legacy(&selection.route.provider)
15119                || !provider_id_is_legacy(&record.provider)
15120        }
15121        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15122            !provider_id_is_legacy(&receipt.provider)
15123        }
15124        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15125            !provider_id_is_legacy(&receipt.spawn.provider)
15126        }
15127        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15128        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15129            !provider_id_is_legacy(&route.provider)
15130        }
15131        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15132            !provider_id_is_legacy(&selection.route.provider)
15133        }
15134        _ => false,
15135    };
15136    if contains_open_record {
15137        reply.result = Err(failure(
15138            NodeFailureCode::UnsupportedCapability,
15139            "open provider IDs were not negotiated",
15140        ));
15141        return;
15142    }
15143    let Ok(response) = reply.result.as_mut() else {
15144        return;
15145    };
15146    match response {
15147        NodeResponse::Snapshot { snapshot, .. } => {
15148            project_snapshot_legacy_provider_ids(snapshot);
15149        }
15150        NodeResponse::Resync { snapshot, events, .. } => {
15151            project_snapshot_legacy_provider_ids(snapshot);
15152            *events = std::mem::take(events)
15153                .into_iter()
15154                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15155                .collect();
15156        }
15157        NodeResponse::WorkspaceRegistered { workspace }
15158        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15159        | NodeResponse::WorktreeCreated { workspace, .. } => {
15160            workspace
15161                .sessions
15162                .retain(|session| provider_id_is_legacy(&session.agent_id));
15163        }
15164        NodeResponse::Armed { .. }
15165        | NodeResponse::Spawned { .. }
15166        | NodeResponse::Activated { .. }
15167        | NodeResponse::Aborted { .. }
15168        | NodeResponse::ReplyChunkAccepted { .. }
15169        | NodeResponse::CallRejected { .. }
15170        | NodeResponse::DeliveryStageBegun { .. }
15171        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15172        | NodeResponse::DeliveryCommitted { .. }
15173        | NodeResponse::DeliveryStageAborted { .. }
15174        | NodeResponse::WorkspaceInspected { .. }
15175        | NodeResponse::HostDirectoriesBrowsed { .. }
15176        | NodeResponse::WorkspaceFileRead { .. }
15177        | NodeResponse::WorkspaceFileWritten { .. }
15178        | NodeResponse::WorkspaceFileCreated { .. }
15179        | NodeResponse::WorkspaceDirectoryCreated { .. }
15180        | NodeResponse::GitHistoryRead { .. }
15181        | NodeResponse::GitDiffRead { .. }
15182        | NodeResponse::Controller { .. }
15183        | NodeResponse::SpawnAccepted { .. }
15184        | NodeResponse::SpawnSpecAccepted { .. }
15185        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15186        | NodeResponse::ManagedWorktreeCleanup { .. }
15187        | NodeResponse::NativeSessionsCataloged { .. }
15188        | NodeResponse::NativeSessionsPaged { .. }
15189        | NodeResponse::NativeSessionPreviewed { .. }
15190        | NodeResponse::SessionRecordPreviewed { .. }
15191        | NodeResponse::HistoryDiscovered { .. }
15192        | NodeResponse::HistoryLoaded { .. }
15193        | NodeResponse::ContextPackForSessionRecordExported { .. }
15194        | NodeResponse::ContextPackExported { .. }
15195        | NodeResponse::ContextPackForgotten { .. }
15196        | NodeResponse::DurableContextPackResolved { .. }
15197        | NodeResponse::ContextPackBytesRead { .. }
15198        | NodeResponse::SessionRecordUpdated { .. }
15199        | NodeResponse::ProviderSessionIndexed { .. }
15200        | NodeResponse::NativeSessionIndexed { .. }
15201        | NodeResponse::SessionRecordResumed { .. }
15202        | NodeResponse::SessionRecordForgotten { .. }
15203        | NodeResponse::WorkspaceUnregistered { .. }
15204        | NodeResponse::WorktreeRemoved { .. }
15205        | NodeResponse::Accepted
15206        | NodeResponse::ShuttingDown => {}
15207    }
15208}
15209
15210fn project_event_legacy_provider_ids(
15211    shared: &NodeShared,
15212    mut envelope: NodeEventEnvelope,
15213) -> Option<NodeEventEnvelope> {
15214    let include = match &mut envelope.event {
15215        NodeEvent::HarnessMcpReadCall { .. } => true,
15216        NodeEvent::Control { address, .. }
15217        | NodeEvent::AgentStream { address, .. }
15218        | NodeEvent::TerminalFrame { address, .. } => shared
15219            .validate_address(address)
15220            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15221        NodeEvent::WorkspaceAdded { workspace } => {
15222            workspace
15223                .sessions
15224                .retain(|session| provider_id_is_legacy(&session.agent_id));
15225            true
15226        }
15227        NodeEvent::SessionRecordUpserted { record } => {
15228            provider_id_is_legacy(&record.provider)
15229        }
15230        NodeEvent::SessionRecordHistorySummarized { .. } => false,
15231        NodeEvent::SessionRecordRemoved { .. } => shared
15232            .history
15233            .lock()
15234            .unwrap_or_else(|poisoned| poisoned.into_inner())
15235            .removed_record_providers
15236            .get(&envelope.sequence)
15237            .is_some_and(provider_id_is_legacy),
15238        NodeEvent::ControllerChanged { .. }
15239        | NodeEvent::ManagedWorktreeUpserted { .. }
15240        | NodeEvent::ManagedWorktreeRemoved { .. }
15241        | NodeEvent::WorkspaceRemoved { .. }
15242        | NodeEvent::ResyncRequired { .. } => true,
15243    };
15244    include.then_some(envelope)
15245}
15246
15247fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15248    let Ok(response) = reply.result.as_mut() else {
15249        return;
15250    };
15251    match response {
15252        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15253            snapshot.provider_runtime_statuses.clear();
15254        }
15255        NodeResponse::Armed { .. }
15256        | NodeResponse::Spawned { .. }
15257        | NodeResponse::Activated { .. }
15258        | NodeResponse::Aborted { .. }
15259        | NodeResponse::ReplyChunkAccepted { .. }
15260        | NodeResponse::CallRejected { .. }
15261        | NodeResponse::DeliveryStageBegun { .. }
15262        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15263        | NodeResponse::DeliveryCommitted { .. }
15264        | NodeResponse::DeliveryStageAborted { .. }
15265        | NodeResponse::WorkspaceInspected { .. }
15266        | NodeResponse::HostDirectoriesBrowsed { .. }
15267        | NodeResponse::WorkspaceFileRead { .. }
15268        | NodeResponse::WorkspaceFileWritten { .. }
15269        | NodeResponse::WorkspaceFileCreated { .. }
15270        | NodeResponse::WorkspaceDirectoryCreated { .. }
15271        | NodeResponse::GitHistoryRead { .. }
15272        | NodeResponse::GitDiffRead { .. }
15273        | NodeResponse::Controller { .. }
15274        | NodeResponse::SpawnAccepted { .. }
15275        | NodeResponse::SpawnSpecAccepted { .. }
15276        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15277        | NodeResponse::ManagedWorktreeCleanup { .. }
15278        | NodeResponse::NativeSessionsCataloged { .. }
15279        | NodeResponse::NativeSessionsPaged { .. }
15280        | NodeResponse::NativeSessionPreviewed { .. }
15281        | NodeResponse::SessionRecordPreviewed { .. }
15282        | NodeResponse::HistoryDiscovered { .. }
15283        | NodeResponse::HistoryLoaded { .. }
15284        | NodeResponse::ContextPackForSessionRecordExported { .. }
15285        | NodeResponse::ContextPackExported { .. }
15286        | NodeResponse::ContextPackForgotten { .. }
15287        | NodeResponse::DurableContextPackResolved { .. }
15288        | NodeResponse::ContextPackBytesRead { .. }
15289        | NodeResponse::SessionRecordUpdated { .. }
15290        | NodeResponse::ProviderSessionIndexed { .. }
15291        | NodeResponse::NativeSessionIndexed { .. }
15292        | NodeResponse::SessionRecordResumed { .. }
15293        | NodeResponse::SessionRecordForgotten { .. }
15294        | NodeResponse::WorkspaceRegistered { .. }
15295        | NodeResponse::StandaloneWorkspaceCreated { .. }
15296        | NodeResponse::WorkspaceUnregistered { .. }
15297        | NodeResponse::WorktreeCreated { .. }
15298        | NodeResponse::WorktreeRemoved { .. }
15299        | NodeResponse::Accepted
15300        | NodeResponse::ShuttingDown => {}
15301    }
15302}
15303
15304struct ControllerReleaseGuard {
15305    shared: Arc<NodeShared>,
15306    connection_id: u64,
15307}
15308
15309impl Drop for ControllerReleaseGuard {
15310    fn drop(&mut self) {
15311        self.shared.release_controller(self.connection_id);
15312    }
15313}
15314
15315struct AbortTaskOnDrop(AbortHandle);
15316
15317impl Drop for AbortTaskOnDrop {
15318    fn drop(&mut self) {
15319        self.0.abort();
15320    }
15321}
15322
15323/// Reads an unsigned env-config override, following the pattern of every
15324/// other bounded knob in this module: missing/unparsable/zero falls back to
15325/// `default`, and the resolved value is always clamped to `max` so a bad
15326/// override cannot reopen a bound this budget exists to enforce.
15327fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15328    std::env::var(name)
15329        .ok()
15330        .and_then(|value| value.parse::<u64>().ok())
15331        .filter(|value| *value > 0)
15332        .unwrap_or(default)
15333        .min(max)
15334}
15335
15336fn workspace_inspection_time_budget_ms() -> u64 {
15337    env_config_bounded(
15338        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15339        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15340        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15341    )
15342}
15343
15344fn workspace_inspection_entry_cap() -> usize {
15345    env_config_bounded(
15346        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15347        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15348        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15349    ) as usize
15350}
15351
15352/// Tracks the shared walk-side budget across the whole recursive walk: a
15353/// wall-clock deadline (shared with the git phase that runs after the walk)
15354/// and a cap on directory entries visited, whether or not they were pushed
15355/// to the response. Cheap to check per entry — one `Instant::now()` and one
15356/// integer compare.
15357struct WorkspaceWalkBudget {
15358    deadline: Instant,
15359    entry_cap: usize,
15360    entries_visited: u64,
15361    time_budget_exceeded: bool,
15362    entry_cap_exceeded: bool,
15363}
15364
15365impl WorkspaceWalkBudget {
15366    fn new(deadline: Instant, entry_cap: usize) -> Self {
15367        Self {
15368            deadline,
15369            entry_cap,
15370            entries_visited: 0,
15371            time_budget_exceeded: false,
15372            entry_cap_exceeded: false,
15373        }
15374    }
15375
15376    fn is_exhausted(&self) -> bool {
15377        self.time_budget_exceeded || self.entry_cap_exceeded
15378    }
15379
15380    /// Records one visited directory entry (pushed to the response or not)
15381    /// and reports whether the walk must stop now.
15382    fn record_visit(&mut self) -> bool {
15383        self.entries_visited += 1;
15384        if self.entries_visited as usize >= self.entry_cap {
15385            self.entry_cap_exceeded = true;
15386        }
15387        if Instant::now() >= self.deadline {
15388            self.time_budget_exceeded = true;
15389        }
15390        self.is_exhausted()
15391    }
15392}
15393
15394fn collect_workspace_entries(
15395    root: &Path,
15396    deadline: Instant,
15397    entry_cap: usize,
15398) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15399    let mut entries = Vec::new();
15400    let mut truncated = false;
15401    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15402    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15403    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15404    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15405    // require strict global byte order over relative paths.
15406    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15407    (entries, truncated, budget)
15408}
15409
15410/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15411/// `inspect_workspace` response — `None` once the walk and git phase both
15412/// completed inside their shared budget.
15413fn workspace_inspection_truncation(
15414    walk_budget: &WorkspaceWalkBudget,
15415    git_time_budget_exceeded: bool,
15416    elapsed: Duration,
15417) -> Option<WorkspaceInspectionTruncationV1> {
15418    if !walk_budget.time_budget_exceeded
15419        && !walk_budget.entry_cap_exceeded
15420        && !git_time_budget_exceeded
15421    {
15422        return None;
15423    }
15424    Some(WorkspaceInspectionTruncationV1 {
15425        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15426        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15427        git_time_budget_exceeded,
15428        entries_visited: walk_budget.entries_visited,
15429        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15430    })
15431}
15432
15433fn walk_workspace_directory(
15434    directory: &Path,
15435    relative_directory: Option<&RepositoryPath>,
15436    depth: usize,
15437    entries: &mut Vec<WorkspaceEntry>,
15438    truncated: &mut bool,
15439    budget: &mut WorkspaceWalkBudget,
15440) {
15441    if budget.is_exhausted() {
15442        *truncated = true;
15443        return;
15444    }
15445    let read_dir = match std::fs::read_dir(directory) {
15446        Ok(read_dir) => read_dir,
15447        Err(_) => {
15448            *truncated = true;
15449            return;
15450        }
15451    };
15452    let mut children = Vec::new();
15453    for child in read_dir {
15454        match child {
15455            Ok(child) => children.push(child),
15456            Err(_) => *truncated = true,
15457        }
15458    }
15459    children.sort_by_key(|child| child.file_name());
15460    for child in children {
15461        if budget.record_visit() {
15462            *truncated = true;
15463            return;
15464        }
15465        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15466            *truncated = true;
15467            return;
15468        }
15469        let file_type = match child.file_type() {
15470            Ok(file_type) => file_type,
15471            Err(_) => {
15472                *truncated = true;
15473                continue;
15474            }
15475        };
15476        if file_type.is_symlink() {
15477            continue;
15478        }
15479        let name = match child.file_name().into_string() {
15480            Ok(name) => name,
15481            Err(_) => {
15482                *truncated = true;
15483                continue;
15484            }
15485        };
15486        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15487            continue;
15488        }
15489        let relative_path = match relative_directory {
15490            None => windows_repository_path(name),
15491            Some(parent) => parent
15492                .as_utf8()
15493                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15494        };
15495        let Some(relative_path) = relative_path else {
15496            *truncated = true;
15497            continue;
15498        };
15499        let kind = if file_type.is_dir() {
15500            WorkspaceEntryKind::Directory
15501        } else if file_type.is_file() {
15502            WorkspaceEntryKind::File
15503        } else {
15504            continue;
15505        };
15506        entries.push(WorkspaceEntry {
15507            relative_path: relative_path.clone(),
15508            kind,
15509        });
15510        if file_type.is_dir() {
15511            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15512                walk_workspace_directory(
15513                    &child.path(),
15514                    Some(&relative_path),
15515                    depth + 1,
15516                    entries,
15517                    truncated,
15518                    budget,
15519                );
15520            } else {
15521                *truncated = true;
15522            }
15523        }
15524    }
15525}
15526
15527fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15528    if value.contains('\\') {
15529        return None;
15530    }
15531    RepositoryPath::utf8(value).ok()
15532}
15533
15534fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15535    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15536}
15537
15538/// Excludes a directory from the workspace tree walk. The three literal
15539/// names are the common convention-based cases; the `CACHEDIR.TAG` check
15540/// beneath them catches every build-output directory regardless of name
15541/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
15542/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
15543/// directory without maintaining a name heuristic list). The name check
15544/// runs first and returns early so a directory rejected by name never pays
15545/// for the extra `CACHEDIR.TAG` stat.
15546fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15547    if name.eq_ignore_ascii_case(".git")
15548        || name.eq_ignore_ascii_case("target")
15549        || name.eq_ignore_ascii_case("node_modules")
15550    {
15551        return true;
15552    }
15553    path.join("CACHEDIR.TAG").is_file()
15554}
15555
15556fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15557    matches!(
15558        status,
15559        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15560    )
15561}
15562
15563#[derive(Clone, Debug, Eq, PartialEq)]
15564enum ContextPackRepositoryHead {
15565    NotRepository,
15566    Unborn,
15567    Commit(String),
15568}
15569
15570#[derive(Clone, Debug, Eq, PartialEq)]
15571struct ContextPackRepositoryObservation {
15572    head: ContextPackRepositoryHead,
15573    git: GitSnapshot,
15574    files: Vec<ContextPackRepositoryFileSource>,
15575}
15576
15577fn stable_context_pack_repository(
15578    first: ContextPackRepositoryObservation,
15579    second: ContextPackRepositoryObservation,
15580) -> Result<ContextPackRepository, NodeFailure> {
15581    if first != second {
15582        return Err(failure(
15583            NodeFailureCode::ContextPackMaterializationFailed,
15584            "context repository changed during bounded capture",
15585        ));
15586    }
15587    Ok(ContextPackRepository::from_sources(
15588        &second.git,
15589        second.files,
15590    ))
15591}
15592
15593/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
15594/// (and only once) the shared walk+git `deadline` has already passed. Every
15595/// remaining phase in `inspect_git_workspace` checks this immediately
15596/// before issuing its own git call, so a budget spent by the walk (or by an
15597/// earlier git call) skips the rest of the chain instead of paying for it
15598/// anyway.
15599fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15600    if Instant::now() < deadline {
15601        return false;
15602    }
15603    append_git_diagnostic(
15604        snapshot,
15605        &format!("{stage} skipped: workspace inspection time budget exhausted"),
15606    );
15607    true
15608}
15609
15610/// Deadline-aware sibling of `run_git_bounded`, used only by
15611/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
15612/// whatever remains of the shared `deadline` (never more than
15613/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
15614/// of the budget cannot still hand a full per-call timeout to every git
15615/// probe behind it. `inspect_git_workspace` already guards each call site
15616/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
15617/// passed yet here.
15618async fn run_git_bounded_with_deadline(
15619    root: &str,
15620    arguments: &[&str],
15621    output_limit: usize,
15622    deadline: Instant,
15623) -> io::Result<GitCommandOutput> {
15624    let remaining = deadline.checked_duration_since(Instant::now());
15625    let timeout_ms = remaining
15626        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15627        .unwrap_or(0)
15628        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15629    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15630}
15631
15632/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
15633/// `deadline` is the same absolute instant the preceding directory walk was
15634/// bounded by, so time already spent walking counts against what remains
15635/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
15636/// shared budget (not an individual per-call timeout) is what cut the git
15637/// phase short.
15638async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15639    let mut snapshot = GitSnapshot {
15640        is_repository: false,
15641        branch: None,
15642        status: Vec::new(),
15643        recent_commits: Vec::new(),
15644        worktrees: Vec::new(),
15645        managed_worktree: None,
15646        truncated: false,
15647        diagnostic: None,
15648    };
15649    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15650        return (snapshot, true);
15651    }
15652    let repository = match run_git_bounded_with_deadline(
15653        root,
15654        &["rev-parse", "--is-inside-work-tree"],
15655        4 * 1_024,
15656        deadline,
15657    )
15658    .await
15659    {
15660        Ok(output) => output,
15661        Err(error) => {
15662            append_git_diagnostic(
15663                &mut snapshot,
15664                &format!("git inspection unavailable: {error}"),
15665            );
15666            return (snapshot, false);
15667        }
15668    };
15669    snapshot.truncated |= repository.truncated;
15670    if repository.timed_out {
15671        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15672        return (snapshot, false);
15673    }
15674    if !repository.success
15675        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15676    {
15677        let stderr = String::from_utf8_lossy(&repository.stderr);
15678        if !stderr.to_ascii_lowercase().contains("not a git repository") {
15679            append_git_diagnostic(&mut snapshot, stderr.trim());
15680        }
15681        return (snapshot, false);
15682    }
15683    snapshot.is_repository = true;
15684
15685    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15686        return (snapshot, true);
15687    }
15688    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15689        Ok(output) => {
15690            snapshot.truncated |= output.truncated;
15691            if output.timed_out {
15692                append_git_diagnostic(&mut snapshot, "git branch query timed out");
15693            } else if output.success {
15694                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15695                if !branch.is_empty() {
15696                    snapshot.branch = Some(truncate_git_field(
15697                        &branch,
15698                        MAX_REPOSITORY_PATH_BYTES,
15699                        &mut snapshot.truncated,
15700                    ));
15701                }
15702            }
15703        }
15704        Err(error) => append_git_diagnostic(
15705            &mut snapshot,
15706            &format!("git branch query failed: {error}"),
15707        ),
15708    }
15709    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15710        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15711            snapshot.truncated |= output.truncated;
15712            if output.success && !output.timed_out {
15713                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15714                if !head.is_empty() {
15715                    snapshot.branch = Some(format!("detached:{head}"));
15716                }
15717            }
15718        }
15719    }
15720
15721    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15722        return (snapshot, true);
15723    }
15724    match run_git_bounded_with_deadline(
15725        root,
15726        &[
15727            "status",
15728            "--porcelain=v1",
15729            "-z",
15730            "--renames",
15731            "--untracked-files=normal",
15732            "--",
15733            ".",
15734        ],
15735        GIT_OUTPUT_MAX_BYTES,
15736        deadline,
15737    )
15738    .await
15739    {
15740        Ok(output) => {
15741            snapshot.truncated |= output.truncated;
15742            if output.timed_out {
15743                append_git_diagnostic(&mut snapshot, "git status timed out");
15744            } else if output.success {
15745                parse_git_status(&output.stdout, &mut snapshot);
15746            } else {
15747                append_git_diagnostic(
15748                    &mut snapshot,
15749                    String::from_utf8_lossy(&output.stderr).trim(),
15750                );
15751            }
15752        }
15753        Err(error) => append_git_diagnostic(
15754            &mut snapshot,
15755            &format!("git status failed: {error}"),
15756        ),
15757    }
15758
15759    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15760        return (snapshot, true);
15761    }
15762    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15763    match run_git_bounded_with_deadline(
15764        root,
15765        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15766        16 * 1_024,
15767        deadline,
15768    )
15769    .await
15770    {
15771        Ok(output) => {
15772            snapshot.truncated |= output.truncated;
15773            if output.timed_out {
15774                append_git_diagnostic(&mut snapshot, "git log timed out");
15775            } else if output.success {
15776                parse_git_commits(&output.stdout, &mut snapshot);
15777            } else {
15778                let stderr = String::from_utf8_lossy(&output.stderr);
15779                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15780                    append_git_diagnostic(&mut snapshot, stderr.trim());
15781                }
15782            }
15783        }
15784        Err(error) => append_git_diagnostic(
15785            &mut snapshot,
15786            &format!("git log failed: {error}"),
15787        ),
15788    }
15789
15790    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15791        return (snapshot, true);
15792    }
15793    match list_git_worktrees_with_deadline(root, deadline).await {
15794        Ok(worktrees) => {
15795            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15796        }
15797        Err(error) => append_git_diagnostic(
15798            &mut snapshot,
15799            &format!("git worktree list failed: {}", error.message),
15800        ),
15801    }
15802    (snapshot, false)
15803}
15804
15805fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15806    let mut remaining = output;
15807    while !remaining.is_empty() {
15808        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15809            snapshot.truncated = true;
15810            break;
15811        }
15812        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15813            snapshot.truncated = true;
15814            break;
15815        };
15816        let record = &remaining[..record_end];
15817        remaining = &remaining[record_end + 1..];
15818        let has_previous_path = record
15819            .get(..2)
15820            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15821        let previous_path_bytes = if has_previous_path {
15822            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15823                snapshot.truncated = true;
15824                break;
15825            };
15826            let previous = &remaining[..previous_end];
15827            remaining = &remaining[previous_end + 1..];
15828            Some(previous)
15829        } else {
15830            None
15831        };
15832        if record.len() < 4
15833            || record[2] != b' '
15834            || !is_git_status_code(record[0])
15835            || !is_git_status_code(record[1])
15836        {
15837            snapshot.truncated = true;
15838            continue;
15839        }
15840        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15841            snapshot.truncated = true;
15842            continue;
15843        };
15844        let previous_path = match previous_path_bytes {
15845            Some(previous) => {
15846                let Some(previous) = windows_repository_path_from_bytes(previous) else {
15847                    snapshot.truncated = true;
15848                    continue;
15849                };
15850                Some(previous)
15851            }
15852            None => None,
15853        };
15854        snapshot.status.push(GitStatusEntry {
15855            index_status: (record[0] as char).to_string(),
15856            worktree_status: (record[1] as char).to_string(),
15857            path,
15858            previous_path,
15859        });
15860    }
15861}
15862
15863fn is_git_status_code(byte: u8) -> bool {
15864    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15865}
15866
15867fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15868    for line in String::from_utf8_lossy(output).lines() {
15869        let Some((id, summary)) = line.split_once('\u{1f}') else {
15870            continue;
15871        };
15872        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15873            snapshot.truncated = true;
15874            break;
15875        }
15876        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15877        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15878        snapshot.recent_commits.push(GitCommitSummary { id, summary });
15879    }
15880}
15881
15882fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15883    if value.len() <= max_bytes {
15884        return value.to_owned();
15885    }
15886    *truncated = true;
15887    let mut boundary = max_bytes;
15888    while boundary > 0 && !value.is_char_boundary(boundary) {
15889        boundary -= 1;
15890    }
15891    value[..boundary].to_owned()
15892}
15893
15894fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15895    if message.is_empty() {
15896        return;
15897    }
15898    let normalized = message
15899        .chars()
15900        .map(|character| if character.is_control() { ' ' } else { character })
15901        .collect::<String>();
15902    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15903    if !diagnostic.is_empty() {
15904        diagnostic.push_str("; ");
15905    }
15906    diagnostic.push_str(normalized.trim());
15907    snapshot.diagnostic = Some(truncate_git_field(
15908        &diagnostic,
15909        GIT_DIAGNOSTIC_MAX_BYTES,
15910        &mut snapshot.truncated,
15911    ));
15912}
15913
15914async fn run_git_bounded(
15915    root: &str,
15916    arguments: &[&str],
15917    output_limit: usize,
15918) -> io::Result<GitCommandOutput> {
15919    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15920}
15921
15922async fn read_git_history_bounded(
15923    root: &str,
15924    path: Option<&RepositoryPath>,
15925    before: Option<&GitObjectId>,
15926    limit: u16,
15927) -> Result<GitHistoryPage, NodeFailure> {
15928    let path = path
15929        .map(|path| {
15930            path.as_utf8().ok_or_else(|| {
15931                failure(
15932                    NodeFailureCode::InvalidRepositoryPath,
15933                    "git history path must use UTF-8",
15934                )
15935            })
15936        })
15937        .transpose()?
15938        .unwrap_or(".");
15939    let count = (usize::from(limit) + 1).to_string();
15940    let start = before
15941        .map(|revision| format!("{}^", revision.as_str()))
15942        .unwrap_or_else(|| "HEAD".to_owned());
15943    let arguments = [
15944        "--no-pager",
15945        "log",
15946        "--no-decorate",
15947        "--date=iso-strict",
15948        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15949        "-n",
15950        count.as_str(),
15951        start.as_str(),
15952        "--",
15953        path,
15954    ];
15955    let output = run_git_bounded(root, &arguments, 256 * 1_024)
15956        .await
15957        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15958    if output.timed_out {
15959        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15960    }
15961    if !output.success {
15962        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15963    }
15964    let mut commits = parse_git_history(&output.stdout);
15965    let has_more = commits.len() > usize::from(limit) || output.truncated;
15966    commits.truncate(usize::from(limit));
15967    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15968    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15969}
15970
15971fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15972    String::from_utf8_lossy(output)
15973        .split('\u{1e}')
15974        .filter_map(|record| {
15975            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15976            if fields.len() != 9 {
15977                return None;
15978            }
15979            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15980            let parents = if fields[1].is_empty() {
15981                Vec::new()
15982            } else {
15983                fields[1]
15984                    .split(' ')
15985                    .map(|value| GitObjectId::new(value.to_owned()))
15986                    .collect::<Result<Vec<_>, _>>()
15987                    .ok()?
15988            };
15989            Some(GitCommitDetails {
15990                id,
15991                parents,
15992                subject: bounded_git_text(fields[2], 512),
15993                author_name: bounded_git_text(fields[3], 256),
15994                author_email: bounded_git_text(fields[4], 320),
15995                authored_at: bounded_git_text(fields[5], 64),
15996                committer_name: bounded_git_text(fields[6], 256),
15997                committer_email: bounded_git_text(fields[7], 320),
15998                committed_at: bounded_git_text(fields[8], 64),
15999                signature_status: GitSignatureStatus::NoSignature,
16000                signer: None,
16001            })
16002        })
16003        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16004        .collect()
16005}
16006
16007fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16008    let normalized = value
16009        .chars()
16010        .map(|character| if character.is_control() { ' ' } else { character })
16011        .collect::<String>();
16012    if normalized.len() <= max_bytes {
16013        return normalized;
16014    }
16015    let mut boundary = max_bytes;
16016    while boundary > 0 && !normalized.is_char_boundary(boundary) {
16017        boundary -= 1;
16018    }
16019    normalized[..boundary].to_owned()
16020}
16021
16022async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16023    let path = request.path.as_ref().map(|path| {
16024        path.as_utf8()
16025            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16026    }).transpose()?;
16027    let mut arguments = match &request.mode {
16028        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16029        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16030        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16031    };
16032    if let GitDiffMode::Commit { revision } = &request.mode {
16033        arguments.push(revision.as_str());
16034    }
16035    arguments.push("--");
16036    if let Some(path) = path {
16037        arguments.push(path);
16038    }
16039    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16040        .await
16041        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16042    if output.timed_out {
16043        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16044    }
16045    if !output.success {
16046        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16047    }
16048    Ok(GitDiff {
16049        mode: request.mode,
16050        path: request.path,
16051        text: String::from_utf8_lossy(&output.stdout).into_owned(),
16052        truncated: output.truncated,
16053    })
16054}
16055
16056async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16057    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16058    ResponseEnvelope { request_id: envelope.request_id, result }
16059}
16060
16061async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16062    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16063        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16064    }
16065    let read_only = matches!(
16066        &request,
16067        NodeRequest::Snapshot
16068            | NodeRequest::Resync { .. }
16069            | NodeRequest::BrowseHostDirectories { .. }
16070            | NodeRequest::InspectWorkspace { .. }
16071            | NodeRequest::ReadWorkspaceFile { .. }
16072            | NodeRequest::ReadGitHistory { .. }
16073            | NodeRequest::ReadGitDiff { .. }
16074            | NodeRequest::CatalogNativeSessions { .. }
16075            | NodeRequest::PageNativeSessions { .. }
16076            | NodeRequest::PreviewNativeSession { .. }
16077            | NodeRequest::PreviewSessionRecord { .. }
16078    );
16079    let _mutation_guard = if read_only {
16080        None
16081    } else {
16082        let guard = shared.mutation_gate.lock().await;
16083        if shared.shutdown.load(Ordering::Acquire) {
16084            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16085        }
16086        Some(guard)
16087    };
16088    match request {
16089        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16090            event_sequence: shared.current_sequence(),
16091            controller: shared.controller_state(),
16092            snapshot: shared.snapshot(),
16093        }),
16094        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16095        NodeRequest::ArmHarnessMcpReservation {
16096            reservation_id,
16097            activation_digest,
16098            spawn_spec,
16099            launch,
16100            expires_at_unix_ms,
16101        } => {
16102            shared.require_controller(connection_id, role)?;
16103            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16104                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16105            })?;
16106            let expires_at_unix_ms = registry
16107                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16108                .await
16109                .map_err(harness_mcp_failure)?;
16110            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16111        }
16112        NodeRequest::SpawnSpecWithHarnessMcp {
16113            reservation_id,
16114            activation_digest,
16115            spec,
16116            deadline_unix_ms,
16117        } => {
16118            shared.require_controller(connection_id, role)?;
16119            let receipt = shared
16120                .spawn_from_spec_with_harness_mcp(
16121                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16122                )
16123                .await?;
16124            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16125        }
16126        NodeRequest::ActivateHarnessMcpReservation {
16127            reservation_id,
16128            activation_digest,
16129            record_id,
16130            session,
16131        } => {
16132            shared.require_controller(connection_id, role)?;
16133            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16134                current.instance_id == session.session.instance_id
16135                    && current.generation == session.session.generation
16136            }).and_then(|current| current.process_id).ok_or_else(|| {
16137                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16138            })?;
16139            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16140                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16141            })?.activate(
16142                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16143            ).map_err(harness_mcp_failure)?;
16144            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16145        }
16146        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16147            shared.require_controller(connection_id, role)?;
16148            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16149                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16150            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16151            if let Some(instance_id) = instance_id {
16152                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16153            }
16154            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16155        }
16156        NodeRequest::PutHarnessMcpReplyChunk {
16157            reservation_id, activation_digest, record_id, session, call_id,
16158            offset, final_chunk, chunk_hex,
16159        } => {
16160            shared.require_controller(connection_id, role)?;
16161            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16162                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16163            })?.put_reply_chunk(
16164                &reservation_id, &activation_digest, &record_id, &session, &call_id,
16165                offset, final_chunk, &chunk_hex,
16166            ).map_err(harness_mcp_failure)?;
16167            Ok(NodeResponse::ReplyChunkAccepted {
16168                reservation_id, activation_digest, record_id, session, call_id,
16169                next_offset, completed,
16170            })
16171        }
16172        NodeRequest::RejectHarnessMcpCall {
16173            reservation_id, activation_digest, record_id, session, call_id, reason,
16174        } => {
16175            shared.require_controller(connection_id, role)?;
16176            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16177                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16178            })?.reject_call(
16179                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16180            ).map_err(harness_mcp_failure)?;
16181            Ok(NodeResponse::CallRejected {
16182                reservation_id, activation_digest, record_id, session, call_id,
16183            })
16184        }
16185        NodeRequest::BeginDeliveryStage { manifest } => {
16186            shared.require_controller(connection_id, role)?;
16187            shared.begin_delivery_stage(manifest)
16188        }
16189        NodeRequest::PutDeliveryBlobChunk {
16190            stage_id,
16191            blob_digest,
16192            offset,
16193            chunk_hex,
16194        } => {
16195            shared.require_controller(connection_id, role)?;
16196            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16197        }
16198        NodeRequest::CommitDeliveryStage { stage_id } => {
16199            shared.require_controller(connection_id, role)?;
16200            let receipt = shared.commit_delivery_stage(stage_id)?;
16201            Ok(NodeResponse::DeliveryCommitted { receipt })
16202        }
16203        NodeRequest::AbortDeliveryStage { stage_id } => {
16204            shared.require_controller(connection_id, role)?;
16205            shared.abort_delivery_stage(&stage_id)?;
16206            Ok(NodeResponse::DeliveryStageAborted { stage_id })
16207        }
16208        NodeRequest::BrowseHostDirectories { directory, after } => {
16209            let listing = shared.browse_host_directories(directory, after).await?;
16210            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16211        }
16212        NodeRequest::InspectWorkspace { workspace_id } => {
16213            let inspection = shared.inspect_workspace(workspace_id).await?;
16214            Ok(NodeResponse::WorkspaceInspected { inspection })
16215        }
16216        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16217            let file = shared.read_workspace_file(workspace_id, path).await?;
16218            Ok(NodeResponse::WorkspaceFileRead { file })
16219        }
16220        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16221            shared.require_controller(connection_id, role)?;
16222            let file = shared
16223                .write_workspace_file(workspace_id, path, expected_revision, text)
16224                .await?;
16225            Ok(NodeResponse::WorkspaceFileWritten { file })
16226        }
16227        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16228            shared.require_controller(connection_id, role)?;
16229            let file = shared.create_workspace_file(workspace_id, path).await?;
16230            Ok(NodeResponse::WorkspaceFileCreated { file })
16231        }
16232        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16233            shared.require_controller(connection_id, role)?;
16234            let entry = shared
16235                .create_workspace_directory(workspace_id.clone(), path)
16236                .await?;
16237            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16238        }
16239        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16240            if role != ClientRole::Operator {
16241                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16242            }
16243            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16244                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16245            }
16246            let page = shared
16247                .read_git_history(workspace_id.clone(), path, before, limit)
16248                .await?;
16249            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16250        }
16251        NodeRequest::ReadGitDiff { workspace_id, request } => {
16252            if role != ClientRole::Operator {
16253                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16254            }
16255            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16256            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16257        }
16258        NodeRequest::CatalogNativeSessions { route, limit } => {
16259            if role != ClientRole::Operator {
16260                return Err(failure(
16261                    NodeFailureCode::ObserverReadOnly,
16262                    "native session catalog requires an operator connection",
16263                ));
16264            }
16265            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16266                return Err(failure(
16267                    NodeFailureCode::InvalidRequest,
16268                    "native session catalog limit is invalid",
16269                ));
16270            }
16271            let (entries, summary) = shared
16272                .catalog_native_sessions(route.clone(), limit)
16273                .await?;
16274            Ok(NodeResponse::NativeSessionsCataloged {
16275                route,
16276                entries,
16277                summary: Some(summary),
16278            })
16279        }
16280        NodeRequest::PageNativeSessions {
16281            route,
16282            window,
16283            catalog_revision,
16284            recent_cutoff_unix_ms,
16285            after_selection_id,
16286            limit,
16287        } => {
16288            if role != ClientRole::Operator {
16289                return Err(failure(
16290                    NodeFailureCode::ObserverReadOnly,
16291                    "native session catalog paging requires an operator connection",
16292                ));
16293            }
16294            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16295                || after_selection_id
16296                    .as_deref()
16297                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16298            {
16299                return Err(failure(
16300                    NodeFailureCode::InvalidRequest,
16301                    "native session catalog page request is invalid",
16302                ));
16303            }
16304            let page = shared
16305                .page_native_sessions(
16306                    route.clone(),
16307                    window,
16308                    catalog_revision,
16309                    recent_cutoff_unix_ms,
16310                    after_selection_id,
16311                    limit,
16312                )
16313                .await?;
16314            Ok(NodeResponse::NativeSessionsPaged {
16315                route,
16316                page,
16317            })
16318        }
16319        NodeRequest::PreviewNativeSession {
16320            selection,
16321            message_limit,
16322        } => {
16323            if role != ClientRole::Operator {
16324                return Err(failure(
16325                    NodeFailureCode::ObserverReadOnly,
16326                    "native session preview requires an operator connection",
16327                ));
16328            }
16329            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16330                .contains(&message_limit)
16331                || selection.validate().is_err()
16332            {
16333                return Err(failure(
16334                    NodeFailureCode::InvalidRequest,
16335                    "native session preview request is invalid",
16336                ));
16337            }
16338            let preview = shared
16339                .preview_native_session(selection.clone(), message_limit)
16340                .await?;
16341            Ok(NodeResponse::NativeSessionPreviewed {
16342                selection,
16343                preview,
16344            })
16345        }
16346        NodeRequest::IndexNativeSession {
16347            selection,
16348            display_name,
16349        } => {
16350            shared.require_controller(connection_id, role)?;
16351            let record = shared
16352                .index_native_session(selection.clone(), display_name)
16353                .await?;
16354            Ok(NodeResponse::NativeSessionIndexed { selection, record })
16355        }
16356        NodeRequest::PreviewSessionRecord {
16357            record_id,
16358            message_limit,
16359        } => {
16360            if role != ClientRole::Operator {
16361                return Err(failure(
16362                    NodeFailureCode::ObserverReadOnly,
16363                    "native session preview requires an operator connection",
16364                ));
16365            }
16366            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16367                .contains(&message_limit)
16368            {
16369                return Err(failure(
16370                    NodeFailureCode::InvalidRequest,
16371                    "native session preview request is invalid",
16372                ));
16373            }
16374            let record = shared.record(&record_id)?;
16375            let working_directory = shared.workspace_root(&record.workspace_id)?;
16376            if !platform::roots_equal(
16377                &working_directory,
16378                windows_path_text(&record.canonical_root),
16379            ) {
16380                return Err(failure(
16381                    NodeFailureCode::SessionRecordConflict,
16382                    "managed session workspace root changed; refusing to preview another directory",
16383                ));
16384            }
16385            let identity = record.provider_session.clone().ok_or_else(|| {
16386                failure(
16387                    NodeFailureCode::BackendOperationFailed,
16388                    "native session preview is unavailable for this session record",
16389                )
16390            })?;
16391            if identity.key != ProviderSessionKey::SessionId {
16392                return Err(failure(
16393                    NodeFailureCode::BackendOperationFailed,
16394                    "native session preview is unavailable for this session identity",
16395                ));
16396            }
16397            let preview = shared
16398                .preview_native_session_by_session_id(
16399                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16400                )
16401                .await?;
16402            if preview.session_id != identity.id {
16403                return Err(failure(
16404                    NodeFailureCode::SessionRecordConflict,
16405                    "native session preview identity changed while loading",
16406                ));
16407            }
16408            shared.revalidate_session_record_preview(&record, &identity)?;
16409            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16410            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16411                record_id: record_id.clone(),
16412                summary: SessionHistorySummaryV1::from(&preview),
16413            });
16414            Ok(NodeResponse::SessionRecordPreviewed {
16415                record_id,
16416                preview,
16417            })
16418        }
16419        NodeRequest::AcquireController { lease_ms } => {
16420            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16421            Ok(NodeResponse::Controller { controller: Some(controller) })
16422        }
16423        NodeRequest::ReleaseController => {
16424            shared.release_controller(connection_id);
16425            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16426        }
16427        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16428            shared.require_controller(connection_id, role)?;
16429            let root = require_windows_path(root)?;
16430            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16431            let workspace = shared.register_workspace(workspace_id, root).await?;
16432            Ok(NodeResponse::WorkspaceRegistered { workspace })
16433        }
16434        NodeRequest::CreateStandaloneWorkspace {
16435            workspace_id,
16436            root,
16437            initial_branch,
16438        } => {
16439            shared.require_controller(connection_id, role)?;
16440            let root = require_windows_path(root)?;
16441            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16442            let workspace = shared
16443                .create_standalone_workspace(workspace_id, root, initial_branch)
16444                .await?;
16445            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16446        }
16447        NodeRequest::UnregisterWorkspace { workspace_id } => {
16448            shared.require_controller(connection_id, role)?;
16449            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16450            shared.unregister_workspace(&workspace_id)?;
16451            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16452        }
16453        NodeRequest::CreateWorktree {
16454            source_workspace_id,
16455            workspace_id,
16456            target_root,
16457            branch,
16458            base,
16459        } => {
16460            shared.require_controller(connection_id, role)?;
16461            let target_root = require_windows_path(target_root)?;
16462            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16463            let (worktree, workspace) = shared
16464                .create_worktree(
16465                    source_workspace_id,
16466                    workspace_id,
16467                    target_root,
16468                    branch,
16469                    base,
16470                )
16471                .await?;
16472            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16473        }
16474        NodeRequest::RemoveWorktree {
16475            source_workspace_id,
16476            target_root,
16477        } => {
16478            shared.require_controller(connection_id, role)?;
16479            let native_target_root = require_windows_path(target_root.clone())?;
16480            shared.reject_managed_reservation(None, Some(&native_target_root))?;
16481            let workspace_id = shared
16482                .remove_worktree(source_workspace_id, native_target_root)
16483                .await?;
16484            Ok(NodeResponse::WorktreeRemoved {
16485                target_root,
16486                workspace_id,
16487            })
16488        }
16489        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16490            shared.require_controller(connection_id, role)?;
16491            if !terminal_size.is_valid() {
16492                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16493            }
16494            if let Some(prompt) = initial_prompt.as_deref() {
16495                validate_node_text("spawn initial prompt", prompt)?;
16496            }
16497            match shared
16498                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16499                .await
16500            {
16501                Ok(session) => {
16502                    shared.mark_spawn_provisional(session.clone());
16503                    Ok(NodeResponse::SpawnAccepted { session })
16504                }
16505                Err(err) => {
16506                    tracing::warn!(
16507                        %provider,
16508                        %workspace_id,
16509                        ?mode,
16510                        code = ?err.code,
16511                        message = %err.message,
16512                        "session spawn refused"
16513                    );
16514                    Err(err)
16515                }
16516            }
16517        }
16518        NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16519            shared.require_controller(connection_id, role)?;
16520            if !terminal_size.is_valid() {
16521                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16522            }
16523            if let Some(prompt) = initial_prompt.as_deref() {
16524                validate_node_text("spawn initial prompt", prompt)?;
16525            }
16526            let plan = backend_paths::plan_launch_options(
16527                provider.as_str(),
16528                mode,
16529                options.model.is_some(),
16530                options.effort.is_some(),
16531            )
16532            .map_err(|r| {
16533                tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16534                failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16535            })?;
16536            let selection = spawn_selection(&options)?;
16537            let pty_selection = match plan {
16538                backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16539                _ => None,
16540            };
16541            let spawned = SPAWN_SESSION_OPTIONS
16542                .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16543                .await;
16544            match spawned {
16545                Ok(session) => {
16546                    if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16547                        shared
16548                            .pending_acp_options
16549                            .lock()
16550                            .unwrap_or_else(|p| p.into_inner())
16551                            .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16552                    }
16553                    shared.mark_spawn_provisional(session.clone());
16554                    tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
16555                    Ok(NodeResponse::SpawnAccepted { session })
16556                }
16557                Err(err) => {
16558                    tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
16559                    Err(err)
16560                }
16561            }
16562        }
16563        NodeRequest::SpawnSpec { spec } => {
16564            shared.require_controller(connection_id, role)?;
16565            let receipt = shared.spawn_from_spec(spec).await?;
16566            Ok(NodeResponse::SpawnSpecAccepted { receipt })
16567        }
16568        NodeRequest::SpawnManagedWorktree { request } => {
16569            shared.require_controller(connection_id, role)?;
16570            let receipt = shared.spawn_managed_worktree(request).await?;
16571            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16572        }
16573        NodeRequest::SpawnManagedWorktreeV2 { request } => {
16574            shared.require_controller(connection_id, role)?;
16575            let receipt = shared.spawn_managed_worktree_v2(request).await?;
16576            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16577        }
16578        NodeRequest::CleanupManagedWorktree { lease_id } => {
16579            shared.require_controller(connection_id, role)?;
16580            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16581            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16582        }
16583        NodeRequest::DiscoverHistory { session, limit } => {
16584            controlled_session(shared, connection_id, role, &session)?;
16585            let candidates = shared.discover_history(&session, limit).await?;
16586            Ok(NodeResponse::HistoryDiscovered {
16587                session,
16588                candidates,
16589            })
16590        }
16591        NodeRequest::LoadHistory {
16592            session,
16593            candidate_id,
16594        } => {
16595            controlled_session(shared, connection_id, role, &session)?;
16596            let loaded = shared
16597                .load_history(&session, candidate_id)
16598                .await?;
16599            Ok(NodeResponse::HistoryLoaded {
16600                session,
16601                session_id: loaded.session_id,
16602                message_count: loaded.message_count,
16603                completed_turn_count: loaded.completed_turn_count,
16604            })
16605        }
16606        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16607            shared.require_controller(connection_id, role)?;
16608            let context = shared
16609                .export_context_pack_for_session_record(&record_id, &session, false)
16610                .await?;
16611            Ok(NodeResponse::ContextPackForSessionRecordExported {
16612                record_id,
16613                session,
16614                context,
16615            })
16616        }
16617        NodeRequest::ExportContextPack { session } => {
16618            controlled_session(shared, connection_id, role, &session)?;
16619            let context = shared.export_context_pack(&session).await?;
16620            Ok(NodeResponse::ContextPackExported { context })
16621        }
16622        NodeRequest::ForgetContextPack { context_id } => {
16623            shared.require_controller(connection_id, role)?;
16624            shared.forget_context_pack(&context_id)?;
16625            Ok(NodeResponse::ContextPackForgotten { context_id })
16626        }
16627        NodeRequest::ResolveDurableContextPack { context_id } => {
16628            // Read-only, idempotent, no controller lease required: this lets
16629            // a caller preflight "is this durably exported pack still here"
16630            // without holding the Node's mutation controller.
16631            let context = shared
16632                .context_catalog
16633                .read()
16634                .unwrap_or_else(|poisoned| poisoned.into_inner())
16635                .get(&context_id)
16636                .map(|pack| pack.receipt().clone())
16637                .ok_or_else(|| {
16638                    failure(
16639                        NodeFailureCode::UnknownContextPack,
16640                        "context pack is not present",
16641                    )
16642                })?;
16643            Ok(NodeResponse::DurableContextPackResolved { context })
16644        }
16645        NodeRequest::ReadContextPack { digest } => {
16646            // Read-only fetch of the pack's own bytes, keyed by content digest
16647            // rather than the caller's `SpawnContextId`: same no-controller-lease
16648            // rationale as `ResolveDurableContextPack` above, one step further
16649            // down the same catalog.
16650            let (id, byte_len, bytes) = shared
16651                .context_catalog
16652                .read()
16653                .unwrap_or_else(|poisoned| poisoned.into_inner())
16654                .get_by_digest(&digest)
16655                .map(|pack| {
16656                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16657                })
16658                .ok_or_else(|| {
16659                    failure(
16660                        NodeFailureCode::UnknownContextPack,
16661                        &format!("context pack {digest} not on this node"),
16662                    )
16663                })?;
16664            tracing::debug!(
16665                digest = digest.as_str(),
16666                byte_len,
16667                "read context pack bytes",
16668            );
16669            Ok(NodeResponse::ContextPackBytesRead {
16670                pack: ContextPackBytesRead {
16671                    digest,
16672                    id,
16673                    byte_len,
16674                    bytes,
16675                },
16676            })
16677        }
16678        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16679            let provider = controlled_session(shared, connection_id, role, &session)?;
16680            if let Some(prompt) = initial_prompt.as_deref() {
16681                validate_node_text("resume initial prompt", prompt)?;
16682            }
16683            let runtime_requirement = if initial_prompt.is_some() {
16684                ProviderRuntimeRequirement::ResumeWithPrompt
16685            } else {
16686                ProviderRuntimeRequirement::Resume
16687            };
16688            shared.require_session_runtime_policy(&session, runtime_requirement)?;
16689            let runtime_policy = shared
16690                .admit_provider_runtime(&provider, runtime_requirement)
16691                .await?;
16692            let working_directory = shared.bound_workspace_root(&session)?;
16693            let request = ResumeLaunchRequest {
16694                working_directory,
16695                terminal_size,
16696                initial_prompt,
16697            };
16698            request
16699                .validate()
16700                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16701            let command = shared.prepare_command(ControlCommand::Resume {
16702                instance_id: session.session.instance_id,
16703                target: ResumeTarget::CurrentProvider,
16704                runtime_policy,
16705                request,
16706            });
16707            shared.arm_resume(&session, command.id, runtime_policy)?;
16708            let dispatch = shared.dispatch_envelope(command);
16709            if let Err(error) = dispatch {
16710                shared.clear_armed_resume(&session);
16711                return Err(error);
16712            }
16713            Ok(NodeResponse::Accepted)
16714        }
16715        NodeRequest::RenameSessionRecord {
16716            record_id,
16717            display_name,
16718        } => {
16719            shared.require_controller(connection_id, role)?;
16720            let record = shared.rename_session_record(&record_id, display_name)?;
16721            Ok(NodeResponse::SessionRecordUpdated { record })
16722        }
16723        NodeRequest::SetSessionTask {
16724            record_id,
16725            expected_revision,
16726            target,
16727        } => {
16728            shared.require_controller(connection_id, role)?;
16729            let record = shared.set_session_task(&record_id, expected_revision, target)?;
16730            Ok(NodeResponse::SessionRecordUpdated { record })
16731        }
16732        NodeRequest::IndexProviderSession {
16733            workspace_id,
16734            provider,
16735            identity,
16736            display_name,
16737        } => {
16738            shared.require_controller(connection_id, role)?;
16739            let record = shared.index_provider_session(
16740                workspace_id,
16741                provider,
16742                identity,
16743                display_name,
16744            )?;
16745            Ok(NodeResponse::ProviderSessionIndexed { record })
16746        }
16747        NodeRequest::ResumeSessionRecord {
16748            record_id,
16749            terminal_size,
16750            initial_prompt,
16751        } => {
16752            shared.require_controller(connection_id, role)?;
16753            if let Some(prompt) = initial_prompt.as_deref() {
16754                validate_node_text("managed resume initial prompt", prompt)?;
16755            }
16756            let (record, session) = shared
16757                .resume_session_record(
16758                    &record_id,
16759                    terminal_size,
16760                    initial_prompt,
16761                )
16762                .await?;
16763            Ok(NodeResponse::SessionRecordResumed { record, session })
16764        }
16765        NodeRequest::ForgetSessionRecord { record_id } => {
16766            shared.require_controller(connection_id, role)?;
16767            shared.forget_session_record(&record_id).await?;
16768            Ok(NodeResponse::SessionRecordForgotten { record_id })
16769        }
16770        NodeRequest::Prompt { session, text } => {
16771            let agent_id = controlled_session(shared, connection_id, role, &session)?;
16772            validate_node_text("prompt", &text)?;
16773            shared.require_session_runtime_policy(
16774                &session,
16775                ProviderRuntimeRequirement::SemanticPrompt,
16776            )?;
16777            let framing = prompt_framing(&agent_id);
16778            shared
16779                .dispatch_input_bounded(
16780                    &session,
16781                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
16782                )
16783                .await?;
16784            Ok(NodeResponse::Accepted)
16785        }
16786        NodeRequest::Paste { session, text } => {
16787            controlled_session(shared, connection_id, role, &session)?;
16788            validate_node_text("paste", &text)?;
16789            shared.require_session_runtime_policy(
16790                &session,
16791                ProviderRuntimeRequirement::SemanticPrompt,
16792            )?;
16793            shared
16794                .dispatch_input_bounded(
16795                    &session,
16796                    InputAction::InsertDraft(PromptPayload {
16797                        text,
16798                        framing: PromptFraming::BracketedPaste,
16799                    }),
16800                )
16801                .await?;
16802            Ok(NodeResponse::Accepted)
16803        }
16804        NodeRequest::Input { session, text } => {
16805            controlled_session(shared, connection_id, role, &session)?;
16806            validate_node_text("terminal input", &text)?;
16807            shared
16808                .dispatch_input_bounded(
16809                    &session,
16810                    InputAction::TerminalText(TerminalText { text }),
16811                )
16812                .await?;
16813            Ok(NodeResponse::Accepted)
16814        }
16815        NodeRequest::TerminalBytes { session, bytes } => {
16816            controlled_session(shared, connection_id, role, &session)?;
16817            shared.sessionize_spawn(session.session.instance_id);
16818            let action = terminal_bytes_action(bytes)?;
16819            shared
16820                .dispatch_input_bounded(&session, action)
16821                .await?;
16822            Ok(NodeResponse::Accepted)
16823        }
16824        NodeRequest::TerminalControl { session, control } => {
16825            controlled_session(shared, connection_id, role, &session)?;
16826            shared
16827                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16828                .await?;
16829            Ok(NodeResponse::Accepted)
16830        }
16831        NodeRequest::Resize { session, size } => {
16832            controlled_session(shared, connection_id, role, &session)?;
16833            shared.sessionize_spawn(session.session.instance_id);
16834            if !size.is_valid() {
16835                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16836            }
16837            shared.dispatch_resize_bounded(&session, size).await?;
16838            Ok(NodeResponse::Accepted)
16839        }
16840        NodeRequest::Interrupt { session } => {
16841            controlled_session(shared, connection_id, role, &session)?;
16842            shared
16843                .dispatch_input_bounded(
16844                    &session,
16845                    InputAction::TerminalControl(TerminalControl::Interrupt),
16846                )
16847                .await?;
16848            Ok(NodeResponse::Accepted)
16849        }
16850        NodeRequest::Stop { session, force } => {
16851            controlled_session(shared, connection_id, role, &session)?;
16852            shared.sessionize_spawn(session.session.instance_id);
16853            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16854            shared.retire_session_grok_leader(session.session.instance_id);
16855            Ok(NodeResponse::Accepted)
16856        }
16857        NodeRequest::Remove { session } => {
16858            controlled_session(shared, connection_id, role, &session)?;
16859            shared.remove_session(&session).await?;
16860            Ok(NodeResponse::Accepted)
16861        }
16862        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16863            shared.require_controller(connection_id, role)?;
16864            let snapshot = shared.require_acp_session(&session)?;
16865            let interaction = snapshot
16866                .provider
16867                .interactions
16868                .iter()
16869                .find(|interaction| {
16870                    interaction_correlation(
16871                        session.session.instance_id,
16872                        session.session.generation,
16873                        interaction.id.0,
16874                    ) == correlation_id
16875                })
16876                .ok_or_else(|| failure(
16877                    NodeFailureCode::InvalidRequest,
16878                    "unknown interaction correlation",
16879                ))?;
16880            if interaction.status != ProviderInteractionStatus::Pending {
16881                return Err(failure(
16882                    NodeFailureCode::BackendOperationFailed,
16883                    "interaction has already been resolved",
16884                ));
16885            }
16886            response
16887                .validate_for(interaction.interaction_kind)
16888                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16889            let interaction_id = interaction.id;
16890            let response_kind = response.kind();
16891            let response_outcome = response.outcome();
16892            shared.dispatch(ControlCommand::ResolveInteraction {
16893                instance_id: session.session.instance_id,
16894                generation: session.session.generation,
16895                interaction_id,
16896                response,
16897            })?;
16898            // The one line that lets an operator compare what THIS node
16899            // recorded against what the provider itself reports doing --
16900            // measured live on a kimi run where our side resolved `true`
16901            // while the agent reported "rejected" and neither layer had a
16902            // line to line up against the other.
16903            tracing::info!(
16904                node_id = %shared.node_id,
16905                session = ?session.session,
16906                correlation_id = %correlation_id,
16907                interaction_id = interaction_id.0,
16908                response_kind = ?response_kind,
16909                outcome = ?response_outcome,
16910                "interaction resolved",
16911            );
16912            Ok(NodeResponse::Accepted)
16913        }
16914        NodeRequest::SetSessionMode { session, mode_id } => {
16915            shared.require_controller(connection_id, role)?;
16916            shared.require_acp_session(&session)?;
16917            shared.dispatch(ControlCommand::SetSessionMode {
16918                instance_id: session.session.instance_id,
16919                mode_id,
16920            })?;
16921            Ok(NodeResponse::Accepted)
16922        }
16923        NodeRequest::SetSessionConfigOption {
16924            session,
16925            option_id,
16926            value_json,
16927        } => {
16928            shared.require_controller(connection_id, role)?;
16929            shared.require_acp_session(&session)?;
16930            shared.dispatch(ControlCommand::SetSessionConfigOption {
16931                instance_id: session.session.instance_id,
16932                option_id,
16933                value_json,
16934            })?;
16935            Ok(NodeResponse::Accepted)
16936        }
16937        NodeRequest::SetSessionModel { session, model_id } => {
16938            shared.require_controller(connection_id, role)?;
16939            shared.require_acp_session(&session)?;
16940            shared.dispatch(ControlCommand::SetSessionModel {
16941                instance_id: session.session.instance_id,
16942                model_id,
16943            })?;
16944            Ok(NodeResponse::Accepted)
16945        }
16946        NodeRequest::Shutdown => {
16947            shared.require_controller(connection_id, role)?;
16948            shared.begin_shutdown_locked().await?;
16949            Ok(NodeResponse::ShuttingDown)
16950        }
16951    }
16952}
16953
16954fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16955    shared.require_controller(connection_id, role)?;
16956    shared.validate_address(session)
16957}
16958
16959/// Selects the sole history candidate matching `provider_session_id` when
16960/// the managed record carries a verified identity, or the sole candidate in
16961/// the discovered set at all when it does not (see
16962/// `export_context_pack_for_session_record_inner`'s identity match). Either
16963/// way, more than one match refuses instead of guessing which one is exact.
16964fn select_history_candidate(
16965    candidates: &[HistoryCandidateSummary],
16966    provider_session_id: Option<&str>,
16967) -> Result<String, NodeFailure> {
16968    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16969        Some(id) => candidate.session_id_hint == id,
16970        None => true,
16971    });
16972    let candidate = matches.next().ok_or_else(|| {
16973        failure(
16974            NodeFailureCode::SessionRecordConflict,
16975            if provider_session_id.is_some() {
16976                "no history candidate matches the managed provider session"
16977            } else {
16978                "no history candidate exists for the managed session"
16979            },
16980        )
16981    })?;
16982    if matches.next().is_some() {
16983        return Err(failure(
16984            NodeFailureCode::SessionRecordConflict,
16985            if provider_session_id.is_some() {
16986                "history identity is ambiguous for the managed provider session"
16987            } else {
16988                "history is ambiguous without a verified provider session identity"
16989            },
16990        ));
16991    }
16992    Ok(candidate.id.clone())
16993}
16994
16995fn resolved_spawn_profile_summary(
16996    profile: &SpawnProfileDefaults,
16997    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16998) -> Option<SpawnProfileSummary> {
16999    let environment_profile = match profile.environment_profile_id.as_ref() {
17000        None => None,
17001        Some(profile_id) => {
17002            let binding = environment_profiles.get(profile_id)?;
17003            if binding.provider != profile.provider
17004                || binding.native_profile_id(profile.mode).is_none()
17005            {
17006                return None;
17007            }
17008            Some(ResolvedEnvironmentProfileReceipt {
17009                profile_id: binding.id.clone(),
17010                profile_revision: binding.revision.clone(),
17011                network_allowlist: None,
17012                browser_profile_id: None,
17013            })
17014        }
17015    };
17016    Some(SpawnProfileSummary {
17017        id: profile.profile_id.clone(),
17018        revision: profile.revision.clone(),
17019        environment_profile,
17020    })
17021}
17022
17023/// True if `current` is an acceptable version of `expected` at export
17024/// revalidation/commit time.
17025///
17026/// When `allow_clean_detachment` is `false` (the on-demand,
17027/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
17028/// be byte-identical to `expected` and still actively bound to `session` —
17029/// the original, unchanged invariant.
17030///
17031/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
17032/// ALSO be the exact Live -> Dormant transition that the same clean exit this
17033/// export reacts to produces on its own: `state` Live -> Dormant,
17034/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
17035/// advancing. Every other field — including `provider_session`, `context`,
17036/// `bundle`, `task_binding` — must still match exactly. A record that changed
17037/// in any other way (rebind, a different active session, an identity
17038/// conflict, capacity eviction) is rejected either way; this widens nothing
17039/// about which sessions are eligible, only how the record's own expected
17040/// detachment is tolerated mid-export.
17041fn session_record_export_target_matches(
17042    expected: &ManagedSessionRecord,
17043    current: &ManagedSessionRecord,
17044    session: &SessionAddress,
17045    allow_clean_detachment: bool,
17046) -> bool {
17047    if current == expected && current.active_session.as_ref() == Some(session) {
17048        return current.state == ManagedSessionState::Live;
17049    }
17050    allow_clean_detachment
17051        && current.state == ManagedSessionState::Dormant
17052        && current.active_session.is_none()
17053        && current.record_id == expected.record_id
17054        && current.display_name == expected.display_name
17055        && current.provider == expected.provider
17056        && current.mode == expected.mode
17057        && current.workspace_id == expected.workspace_id
17058        && current.canonical_root == expected.canonical_root
17059        && current.provider_session == expected.provider_session
17060        && current.environment_profile == expected.environment_profile
17061        && current.bundle == expected.bundle
17062        && current.context_id == expected.context_id
17063        && current.context == expected.context
17064        && current.exported_context == expected.exported_context
17065        && current.task_binding == expected.task_binding
17066        && current.created_at_unix_ms == expected.created_at_unix_ms
17067        && current.last_error == expected.last_error
17068}
17069
17070fn session_record_context_export_binding_is_exact(
17071    expected: &ManagedSessionRecord,
17072    current: &ManagedSessionRecord,
17073    identity: Option<&ProviderSessionIdentity>,
17074    session: &SessionAddress,
17075    binding: &SessionBinding,
17076    provider: &AgentId,
17077    current_root: &str,
17078    allow_clean_detachment: bool,
17079) -> bool {
17080    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17081        && current.provider_session.as_ref() == identity
17082        && current.workspace_id == session.workspace_id
17083        && provider == &current.provider
17084        && binding.workspace_id == session.workspace_id
17085        && binding.generation == session.session.generation
17086        && binding.record_id.as_ref() == Some(&current.record_id)
17087        && platform::roots_equal(
17088            current_root,
17089            windows_path_text(&current.canonical_root),
17090        )
17091}
17092
17093fn session_record_context_export_source_is_usable(
17094    record: &ManagedSessionRecord,
17095    status: &SessionStatus,
17096    allow_clean_detachment: bool,
17097) -> bool {
17098    (record.state == ManagedSessionState::Live
17099        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17100        && context_pack_source_status_is_usable(status)
17101}
17102
17103fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17104    PromptFraming::BracketedPaste
17105}
17106
17107fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17108    if text.len() > MAX_NODE_TEXT_BYTES {
17109        return Err(failure(
17110            NodeFailureCode::InvalidRequest,
17111            &format!(
17112                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17113                text.len(),
17114            ),
17115        ));
17116    }
17117    Ok(())
17118}
17119
17120fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17121    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17122        return Err(failure(
17123            NodeFailureCode::InvalidRequest,
17124            &format!(
17125                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17126                bytes.len(),
17127            ),
17128        ));
17129    }
17130    Ok(())
17131}
17132
17133fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17134    validate_terminal_bytes(&bytes)?;
17135    Ok(InputAction::TerminalBytes(bytes))
17136}
17137
17138fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17139    if root.is_empty()
17140        || root.len() > MAX_WORKSPACE_ROOT_BYTES
17141        || root.chars().any(char::is_control)
17142    {
17143        return Err(failure(
17144            NodeFailureCode::InvalidWorkspaceRoot,
17145            &format!(
17146                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17147            ),
17148        ));
17149    }
17150    Ok(())
17151}
17152
17153fn canonical_standalone_workspace_candidate(
17154    workspace_id: &WorkspaceId,
17155    root: &str,
17156) -> Result<String, NodeServerError> {
17157    let path = Path::new(root);
17158    match std::fs::symlink_metadata(path) {
17159        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17160            .map(|workspace| workspace.canonical_root),
17161        Err(error) if error.kind() == io::ErrorKind::NotFound => {
17162            if !path.is_absolute() {
17163                return Err(NodeServerError::InvalidWorkspaceRoot {
17164                    workspace_id: workspace_id.clone(),
17165                    path: root.to_owned(),
17166                    message: "path is not absolute".to_owned(),
17167                });
17168            }
17169            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17170                workspace_id: workspace_id.clone(),
17171                path: root.to_owned(),
17172                message: "path has no standalone directory name".to_owned(),
17173            })?;
17174            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17175                workspace_id: workspace_id.clone(),
17176                path: root.to_owned(),
17177                message: "path has no parent directory".to_owned(),
17178            })?;
17179            let canonical = std::fs::canonicalize(parent)
17180                .map(|parent| parent.join(name))
17181                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17182                    workspace_id: workspace_id.clone(),
17183                    path: root.to_owned(),
17184                    message: error.to_string(),
17185                })?;
17186            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17187                NodeServerError::InvalidWorkspaceRoot {
17188                    workspace_id: workspace_id.clone(),
17189                    path: path.to_string_lossy().into_owned(),
17190                    message: "canonical path is not valid Unicode".to_owned(),
17191                }
17192            })?;
17193            let canonical_root = platform::normalize_canonical_root(canonical_root);
17194            validate_workspace_root(workspace_id, &canonical_root)?;
17195            Ok(canonical_root)
17196        }
17197        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17198            workspace_id: workspace_id.clone(),
17199            path: root.to_owned(),
17200            message: error.to_string(),
17201        }),
17202    }
17203}
17204
17205fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17206    let Some(revision) = revision else {
17207        return Ok(());
17208    };
17209    if revision.is_empty()
17210        || revision.len() > MAX_REPOSITORY_PATH_BYTES
17211        || revision.chars().any(char::is_control)
17212    {
17213        return Err(failure(
17214            NodeFailureCode::InvalidRequest,
17215            &format!(
17216                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17217            ),
17218        ));
17219    }
17220    Ok(())
17221}
17222
17223fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17224    let code = match error.kind {
17225        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17226        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17227        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17228        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17229        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17230        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17231        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17232    };
17233    failure(code, &error.message)
17234}
17235
17236fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17237    let code = match error.kind {
17238        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17239        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17240        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17241            NodeFailureCode::BackendOperationFailed
17242        }
17243        StandaloneWorkspaceErrorKind::RecoveryRequired => {
17244            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17245        }
17246    };
17247    failure(code, error.message)
17248}
17249
17250fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17251    let (code, message) = match error.kind {
17252        GitWorktreeErrorKind::Invalid => (
17253            NodeFailureCode::InvalidRequest,
17254            "managed Git input is invalid",
17255        ),
17256        GitWorktreeErrorKind::NotRepository => (
17257            NodeFailureCode::NotGitRepository,
17258            "managed source is not a Git repository",
17259        ),
17260        GitWorktreeErrorKind::Conflict => (
17261            NodeFailureCode::WorktreeConflict,
17262            "managed Git identity conflicts with existing repository state",
17263        ),
17264        GitWorktreeErrorKind::Protected => (
17265            NodeFailureCode::ManagedWorktreeOwnershipConflict,
17266            "managed worktree ownership validation failed",
17267        ),
17268        GitWorktreeErrorKind::Dirty => (
17269            NodeFailureCode::WorktreeDirty,
17270            "managed worktree contains uncommitted changes",
17271        ),
17272        GitWorktreeErrorKind::Locked => (
17273            NodeFailureCode::WorktreeLocked,
17274            "managed worktree is locked",
17275        ),
17276        GitWorktreeErrorKind::Failed => (
17277            NodeFailureCode::BackendOperationFailed,
17278            "managed Git operation failed",
17279        ),
17280    };
17281    NodeFailure { code, message: message.to_owned() }
17282}
17283
17284fn managed_spawn_receipt(
17285    resolved: &ResolvedSpawnSpec,
17286    incarnation_id: NodeIncarnationId,
17287    session: SessionAddress,
17288    lease: ManagedWorktreeLeaseSnapshot,
17289    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17290    bundle: Option<ResolvedBundleReceipt>,
17291    context: Option<ResolvedContextPackReceipt>,
17292) -> ManagedWorktreeSpawnReceipt {
17293    let mut managed = resolved.clone();
17294    managed.target.worktree_id = Some(lease.workspace_id.clone());
17295    ManagedWorktreeSpawnReceipt {
17296        spawn: managed.receipt_with_materialization(
17297            incarnation_id,
17298            session,
17299            environment_profile,
17300            bundle,
17301            context,
17302        ),
17303        lease,
17304    }
17305}
17306
17307fn validate_selected_worktree(
17308    source_root: &str,
17309    selected_root: &str,
17310    worktrees: &[NativeGitWorktreeSnapshot],
17311) -> Result<(), NodeFailure> {
17312    if !worktrees
17313        .iter()
17314        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17315    {
17316        return Err(failure(
17317            NodeFailureCode::WorktreeProtected,
17318            "source workspace is not an authoritative non-bare Git worktree root",
17319        ));
17320    }
17321    if !worktrees.iter().any(|worktree| {
17322        worktree_paths_equal(&worktree.path, selected_root)
17323            && !worktree.is_main
17324            && !worktree.is_bare
17325    }) {
17326        return Err(failure(
17327            NodeFailureCode::WorktreeProtected,
17328            "selected workspace is not an eligible Git worktree in the source repository",
17329        ));
17330    }
17331    Ok(())
17332}
17333
17334fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17335    let code = match error.kind() {
17336        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17337        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17338            NodeFailureCode::RepositoryPathUnsafe
17339        }
17340        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17341        WorkspaceFileReadErrorKind::NotRegularFile => {
17342            NodeFailureCode::RepositoryFileNotRegular
17343        }
17344        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17345            NodeFailureCode::RepositoryFileReadFailed
17346        }
17347        WorkspaceFileReadErrorKind::RevisionConflict => {
17348            NodeFailureCode::RepositoryFileRevisionConflict
17349        }
17350        WorkspaceFileReadErrorKind::AlreadyExists
17351        | WorkspaceFileReadErrorKind::ParentNotFound
17352        | WorkspaceFileReadErrorKind::ParentNotDirectory
17353        | WorkspaceFileReadErrorKind::Canceled => {
17354            NodeFailureCode::RepositoryFileReadFailed
17355        }
17356    };
17357    failure(code, "repository file read failed")
17358}
17359
17360fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17361    let code = match error.kind() {
17362        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17363        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17364            NodeFailureCode::RepositoryPathUnsafe
17365        }
17366        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17367        WorkspaceFileReadErrorKind::NotRegularFile => {
17368            NodeFailureCode::RepositoryFileNotRegular
17369        }
17370        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17371            NodeFailureCode::RepositoryFileWriteFailed
17372        }
17373        WorkspaceFileReadErrorKind::RevisionConflict => {
17374            NodeFailureCode::RepositoryFileRevisionConflict
17375        }
17376        WorkspaceFileReadErrorKind::AlreadyExists
17377        | WorkspaceFileReadErrorKind::ParentNotFound
17378        | WorkspaceFileReadErrorKind::ParentNotDirectory
17379        | WorkspaceFileReadErrorKind::Canceled => {
17380            NodeFailureCode::RepositoryFileWriteFailed
17381        }
17382    };
17383    failure(code, "repository file write failed")
17384}
17385
17386async fn settle_workspace_entry_create<T>(
17387    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17388    commit_state: Arc<AtomicU8>,
17389    deadline: Duration,
17390    timeout_message: &'static str,
17391    task_failure_message: &'static str,
17392) -> Result<T, NodeFailure> {
17393    let joined = match timeout(deadline, &mut task).await {
17394        Ok(joined) => joined,
17395        Err(_) => {
17396            if commit_state
17397                .compare_exchange(
17398                    WORKSPACE_ENTRY_CREATE_PENDING,
17399                    WORKSPACE_ENTRY_CREATE_CANCELED,
17400                    Ordering::AcqRel,
17401                    Ordering::Acquire,
17402                )
17403                .is_ok()
17404            {
17405                return Err(failure(
17406                    NodeFailureCode::RepositoryEntryCreateTimedOut,
17407                    timeout_message,
17408                ));
17409            }
17410            debug_assert_eq!(
17411                commit_state.load(Ordering::Acquire),
17412                WORKSPACE_ENTRY_CREATE_COMMITTING,
17413            );
17414            task.await
17415        }
17416    };
17417    joined
17418        .map_err(|_| {
17419            failure(
17420                NodeFailureCode::RepositoryEntryCreateFailed,
17421                task_failure_message,
17422            )
17423        })?
17424        .map_err(workspace_entry_create_failure)
17425}
17426
17427fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17428    let code = match error.kind() {
17429        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17430        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17431            NodeFailureCode::RepositoryPathUnsafe
17432        }
17433        WorkspaceFileReadErrorKind::AlreadyExists => {
17434            NodeFailureCode::RepositoryEntryAlreadyExists
17435        }
17436        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17437            NodeFailureCode::RepositoryParentNotFound
17438        }
17439        WorkspaceFileReadErrorKind::ParentNotDirectory
17440        | WorkspaceFileReadErrorKind::NotRegularFile => {
17441            NodeFailureCode::RepositoryParentNotDirectory
17442        }
17443        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17444            NodeFailureCode::RepositoryEntryCreateFailed
17445        }
17446        WorkspaceFileReadErrorKind::RevisionConflict => {
17447            NodeFailureCode::RepositoryEntryCreateFailed
17448        }
17449        WorkspaceFileReadErrorKind::Canceled => {
17450            NodeFailureCode::RepositoryEntryCreateTimedOut
17451        }
17452    };
17453    failure(code, "repository entry create failed")
17454}
17455
17456fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17457    let value = digest(&SHA256, bytes)
17458        .as_ref()
17459        .iter()
17460        .map(|byte| format!("{byte:02x}"))
17461        .collect();
17462    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17463}
17464
17465fn managed_spawn_request_digest_v2(
17466    request: &ManagedWorktreeSpawnRequestV2,
17467) -> Result<String, NodeFailure> {
17468    let encoded = serde_json::to_vec(request).map_err(|_| {
17469        failure(
17470            NodeFailureCode::InvalidRequest,
17471            "managed worktree V2 request could not be canonicalized",
17472        )
17473    })?;
17474    let mut value = String::with_capacity(71);
17475    value.push_str("sha256:");
17476    for byte in digest(&SHA256, &encoded).as_ref() {
17477        use std::fmt::Write as _;
17478        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17479    }
17480    Ok(value)
17481}
17482
17483fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17484    let code = match error {
17485        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17486        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17487        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17488        NativeSessionPreviewError::UnsupportedProvider
17489        | NativeSessionPreviewError::PreviewUnavailable
17490        | NativeSessionPreviewError::SessionNotFound
17491        | NativeSessionPreviewError::AmbiguousSession => {
17492            NodeFailureCode::BackendOperationFailed
17493        }
17494    };
17495    failure(code, "native session preview failed")
17496}
17497
17498fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17499    let code = match error.kind() {
17500        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17501        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17502    };
17503    failure(code, "host directory browse failed")
17504}
17505
17506fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17507    let code = match error {
17508        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17509        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17510        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17511            NodeFailureCode::DeliveryStageConflict
17512        }
17513        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17514        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17515            NodeFailureCode::DeliveryChunkOutOfOrder
17516        }
17517        DeliveryStoreError::BlobDigestMismatch => {
17518            NodeFailureCode::DeliveryBlobDigestMismatch
17519        }
17520        DeliveryStoreError::BundleDigestMismatch => {
17521            NodeFailureCode::DeliveryBundleDigestMismatch
17522        }
17523        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17524        DeliveryStoreError::Unavailable
17525        | DeliveryStoreError::Corrupt
17526        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17527    };
17528    failure(code, "delivery operation failed")
17529}
17530
17531fn validate_workspace_root(
17532    workspace_id: &WorkspaceId,
17533    canonical_root: &str,
17534) -> Result<(), NodeServerError> {
17535    #[cfg(windows)]
17536    if canonical_root.starts_with(r"\\") {
17537        return Err(NodeServerError::InvalidWorkspaceRoot {
17538            workspace_id: workspace_id.clone(),
17539            path: canonical_root.to_owned(),
17540            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17541        });
17542    }
17543    if !platform::workspace_root_supported(canonical_root)
17544        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17545    {
17546        return Err(NodeServerError::InvalidWorkspaceRoot {
17547            workspace_id: workspace_id.clone(),
17548            path: canonical_root.to_owned(),
17549            message: "canonical path must be absolute, bounded, and free of control characters"
17550                .to_owned(),
17551        });
17552    }
17553    Ok(())
17554}
17555
17556fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17557    NodeFailure {
17558        code,
17559        message: node_failure_category(code).to_owned(),
17560    }
17561}
17562
17563fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17564    let code = match error {
17565        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17566        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17567        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17568        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17569        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17570        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17571        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17572        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17573        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17574    };
17575    failure(code, "harness MCP proxy operation failed")
17576}
17577
17578/// Numbers each harness-MCP overlay this process constructs, in
17579/// construction order, so two overlays built back to back in one node process
17580/// still get distinct trace files.
17581static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17582
17583/// Builds the optional trace environment entry of one harness-MCP overlay.
17584/// The caller's launch description names both the entry and the node-process
17585/// variable that opts in: when that variable holds a non-empty directory, the
17586/// entry names a trace file under it. Returns `None` when the launch has no
17587/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
17588/// the directory; that is the operator's job.
17589fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17590    let trace = launch.trace.as_ref()?;
17591    let trace_dir = std::env::var(&trace.dir_env).ok()?;
17592    if trace_dir.is_empty() {
17593        return None;
17594    }
17595    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17596    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17597    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17598}
17599
17600fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17601    let remaining = deadline.saturating_duration_since(Instant::now());
17602    if remaining.is_zero() {
17603        Err(failure(
17604            NodeFailureCode::SpawnDeadlineExceeded,
17605            "spawn deadline elapsed",
17606        ))
17607    } else {
17608        Ok(remaining)
17609    }
17610}
17611
17612fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17613    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17614    match deadline {
17615        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17616        None => Ok(bounded),
17617    }
17618}
17619
17620fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17621    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17622        failure(
17623            NodeFailureCode::SpawnDeadlineExceeded,
17624            "spawn dispatch exceeded the spawn deadline",
17625        )
17626    } else {
17627        error
17628    }
17629}
17630
17631fn spawn_runtime_capabilities(
17632    capabilities: &SpawnRequiredCapabilities,
17633) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17634    capabilities
17635        .iter()
17636        .map(|capability| match capability.as_str() {
17637            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17638            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17639            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17640            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17641                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17642            }
17643            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17644            _ => Err(failure(
17645                NodeFailureCode::UnsupportedSpawnCapability,
17646                "spawn required capability is unknown",
17647            )),
17648        })
17649        .collect()
17650}
17651
17652fn persistence_failure(_error: io::Error) -> NodeFailure {
17653    NodeFailure {
17654        code: NodeFailureCode::BackendOperationFailed,
17655        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17656    }
17657}
17658
17659fn node_failure_category(code: NodeFailureCode) -> &'static str {
17660    match code {
17661        NodeFailureCode::InvalidRequest => "invalid-request",
17662        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17663        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17664        NodeFailureCode::ReservationNotFound => "reservation-not-found",
17665        NodeFailureCode::ReservationConflict => "reservation-conflict",
17666        NodeFailureCode::ReservationExpired => "reservation-expired",
17667        NodeFailureCode::BindingMismatch => "binding-mismatch",
17668        NodeFailureCode::NotActivated => "not-activated",
17669        NodeFailureCode::CallNotFound => "call-not-found",
17670        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17671        NodeFailureCode::ResponseTooLarge => "response-too-large",
17672        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17673        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17674        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17675        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17676        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17677        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17678        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17679        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17680        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17681        NodeFailureCode::Unauthorized => "unauthorized",
17682        NodeFailureCode::ObserverReadOnly => "observer-read-only",
17683        NodeFailureCode::ControllerBusy => "controller-busy",
17684        NodeFailureCode::ControllerRequired => "controller-required",
17685        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17686        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17687        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17688        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17689        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17690        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17691        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17692        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17693        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17694        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17695        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17696        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17697        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17698        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17699        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17700        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17701        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17702        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17703        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17704        NodeFailureCode::GitReadFailed => "git-read-failed",
17705        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17706        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17707        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17708        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17709        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17710        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17711        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17712        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17713        NodeFailureCode::TurnInFlight => "turn-in-flight",
17714        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17715        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17716        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17717            "unsupported-network-allowlist-mapping"
17718        }
17719        NodeFailureCode::BrowserStationProbeUnavailable => {
17720            "browser-station-probe-unavailable"
17721        }
17722        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17723        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17724        NodeFailureCode::EnvironmentProfileBindingMismatch => {
17725            "environment-profile-binding-mismatch"
17726        }
17727        NodeFailureCode::UnknownBundle => "unknown-bundle",
17728        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17729        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17730        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17731        NodeFailureCode::ContextPackBusy => "context-pack-busy",
17732        NodeFailureCode::ContextPackMaterializationFailed => {
17733            "context-pack-materialization-failed"
17734        }
17735        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17736        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17737        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17738        NodeFailureCode::WorkspaceBusy => "workspace-busy",
17739        NodeFailureCode::LastWorkspace => "last-workspace",
17740        NodeFailureCode::NotGitRepository => "not-git-repository",
17741        NodeFailureCode::WorktreeConflict => "worktree-conflict",
17742        NodeFailureCode::WorktreeProtected => "worktree-protected",
17743        NodeFailureCode::WorktreeDirty => "worktree-dirty",
17744        NodeFailureCode::WorktreeLocked => "worktree-locked",
17745        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17746        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17747        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17748        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17749            "managed-worktree-profile-revision-mismatch"
17750        }
17751        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17752        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17753            "standalone-workspace-recovery-required"
17754        }
17755        NodeFailureCode::UnknownSession => "unknown-session",
17756        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17757        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17758        NodeFailureCode::SessionRecordBusy => "session-record-busy",
17759        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17760        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17761        NodeFailureCode::WorkspaceRegistrationRequired => {
17762            "workspace-registration-required"
17763        }
17764        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17765        NodeFailureCode::StaleGeneration => "stale-generation",
17766        NodeFailureCode::BackendBusy => "backend-busy",
17767        NodeFailureCode::BackendDisconnected => "backend-disconnected",
17768        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17769        NodeFailureCode::ShuttingDown => "shutting-down",
17770    }
17771}
17772
17773fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17774    NodeServerError::DurableState(io::Error::new(error.kind(), category))
17775}
17776
17777fn durable_state_load_error(error: io::Error) -> NodeServerError {
17778    let category = match session_registry::state_load_refusal(&error) {
17779        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17780            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17781        }
17782        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17783            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17784        }
17785        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17786            DURABLE_STATE_CONFLICT_ERROR
17787        }
17788        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17789    };
17790    durable_state_server_error(error, category)
17791}
17792
17793fn unix_time_ms() -> u64 {
17794    std::time::SystemTime::now()
17795        .duration_since(std::time::UNIX_EPOCH)
17796        .unwrap_or_default()
17797        .as_millis()
17798        .min(u64::MAX as u128) as u64
17799}
17800
17801fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17802    if record.provider_session.is_some() {
17803        ManagedSessionState::Dormant
17804    } else {
17805        ManagedSessionState::Unavailable
17806    }
17807}
17808
17809fn active_record_state(
17810    identity_admitted: bool,
17811    provider_session_is_known: bool,
17812) -> ManagedSessionState {
17813    if identity_admitted && !provider_session_is_known {
17814        ManagedSessionState::IdentityPending
17815    } else {
17816        ManagedSessionState::Live
17817    }
17818}
17819
17820fn managed_worktree_record_holders(
17821    records: &[ManagedSessionRecord],
17822) -> Vec<(SessionRecordId, WorkspaceId)> {
17823    records
17824        .iter()
17825        .filter(|record| record.provider_session.is_some())
17826        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17827        .collect()
17828}
17829
17830fn managed_spawn_record_ownership(
17831    binding: &SessionBinding,
17832    effective_runtime_policy: ProviderRuntimePolicy,
17833) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17834    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17835    if effective_runtime_policy.provider_session_identity {
17836        (binding.record_id.clone(), None)
17837    } else {
17838        (None, binding.record_id.clone())
17839    }
17840}
17841
17842#[derive(Debug, Error)]
17843pub enum NodeServerError {
17844    #[error("node endpoint must be a bounded absolute local transport path")]
17845    InvalidEndpoint,
17846    #[error("node access token must contain 1..=4096 bytes")]
17847    InvalidAccessToken,
17848    #[error("node HTTP observer must listen on a loopback address: {0}")]
17849    InvalidApiListen(std::net::SocketAddr),
17850    #[error("node browser bridge must listen on a loopback address: {0}")]
17851    InvalidBridgeListen(std::net::SocketAddr),
17852    #[error("node browser bridge token must contain 1..=4096 bytes")]
17853    InvalidBridgeToken,
17854    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17855    BridgeUnderlayRequiresBridgeListen,
17856    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17857    BridgeUnderlayRequiresBridgeToken,
17858    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17859    InvalidBridgeUnderlayToken,
17860    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17861    InvalidBridgeUnderlayKey,
17862    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17863    InvalidCallHome(std::net::SocketAddr),
17864    #[error("node could not reach its call-home relay: {0}")]
17865    CallHomeTransport(#[source] std::io::Error),
17866    #[error("node durable state path must be an absolute file path: {0}")]
17867    InvalidStatePath(String),
17868    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17869    InvalidHarnessMcpHelper,
17870    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17871    InvalidNetworkAllowlistCatalogPath(String),
17872    #[error("network allowlist catalog could not be read: {0}")]
17873    NetworkAllowlistCatalogIo(#[source] std::io::Error),
17874    #[error("network allowlist catalog line {line}: {message}")]
17875    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17876    #[error("network allowlist catalog: {0}")]
17877    InvalidNetworkAllowlistCatalog(String),
17878    #[error("network allowlist catalog exceeds the {max}-entry limit")]
17879    NetworkAllowlistCatalogCapacity { max: usize },
17880    #[error("the local state directory is unavailable; supply an explicit state path")]
17881    LocalStateDirectoryUnavailable,
17882    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17883    LocalRuntimeDirectoryUnavailable,
17884    #[error("node durable state failed: {0}")]
17885    DurableState(io::Error),
17886    #[error("node requires at least one configured workspace")]
17887    NoWorkspaces,
17888    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17889    InvalidWorkspaceRoot {
17890        workspace_id: WorkspaceId,
17891        path: String,
17892        message: String,
17893    },
17894    #[error("duplicate workspace ID: {0}")]
17895    DuplicateWorkspaceId(WorkspaceId),
17896    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17897    DuplicateWorkspaceRoot {
17898        first: WorkspaceId,
17899        second: WorkspaceId,
17900        root: String,
17901    },
17902    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17903    ManagedWorktreeProfileRequired(WorkspaceId),
17904    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17905    ManagedWorktreeProfileModeMismatch(WorkspaceId),
17906    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17907    DuplicateManagedWorktreeProfile {
17908        workspace_id: WorkspaceId,
17909        profile_id: WorktreeProfileId,
17910    },
17911    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17912    ManagedWorktreeProfileCapacity {
17913        workspace_id: WorkspaceId,
17914        max: usize,
17915    },
17916    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17917    InvalidManagedWorktreeProfile {
17918        workspace_id: WorkspaceId,
17919        profile_id: WorktreeProfileId,
17920        message: String,
17921    },
17922    #[error("duplicate node environment profile: {0}")]
17923    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17924    #[error("node environment profile provider is unavailable: {0}")]
17925    EnvironmentProfileProviderUnavailable(AgentId),
17926    #[error("node environment profile capacity is {max}")]
17927    EnvironmentProfileCapacity { max: usize },
17928    #[error("node environment profiles reuse native profile ID '{0}'")]
17929    DuplicateNativeEnvironmentProfile(String),
17930    #[error("node native environment profile is invalid: {0}")]
17931    NativeEnvironmentProfile(String),
17932    #[error("node session-environment materialization root must be absolute")]
17933    InvalidSessionEnvironmentRoot,
17934    #[error("node session-environment materializer is unavailable")]
17935    SessionEnvironmentMaterializerRequired,
17936    #[error("node session-environment materializer failed to initialize")]
17937    SessionEnvironmentMaterializer,
17938    #[error("node bundle catalog is invalid: {0}")]
17939    BundleCatalog(String),
17940    #[error("node delivery store failed to initialize: {0}")]
17941    DeliveryStore(String),
17942    #[error("node context pack store failed to initialize")]
17943    ContextPackStore,
17944    #[error("active agent registry failed: {0}")]
17945    Registry(String),
17946    #[error("node provider contract manifest is invalid: {0}")]
17947    ProviderContractManifest(String),
17948    #[error("named pipe I/O failed: {0}")]
17949    Io(#[from] io::Error),
17950    #[error("node HTTP observer failed: {0}")]
17951    HttpApi(io::Error),
17952    #[error("node browser bridge failed: {0}")]
17953    BridgeApi(io::Error),
17954    #[error(transparent)]
17955    Frame(#[from] FrameError),
17956    #[error("node handshake failed: {0}")]
17957    Handshake(String),
17958    #[error("node authentication frame was not received before the bounded deadline")]
17959    AuthenticationTimedOut,
17960    #[error("node authentication primitive failed: {0}")]
17961    Authentication(String),
17962    #[error("node incarnation identity generation failed: {0}")]
17963    IncarnationIdentity(String),
17964    #[error("node pre-authentication limiter is closed")]
17965    PreauthClosed,
17966    #[error("node authenticated connection limit was reached")]
17967    AuthenticatedConnectionLimit,
17968    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17969    SignalStreamClosed,
17970    #[error("node stop-all dispatch failed: {0:?}")]
17971    ShutdownDispatch(NodeFailure),
17972    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17973    ShutdownTimedOut { active_native_sessions: usize },
17974}
17975
17976impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17977    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17978        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17979        match error {
17980            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17981            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17982            E::InvalidEntry { line, message } => {
17983                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17984            }
17985            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17986            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17987        }
17988    }
17989}
17990
17991#[cfg(test)]
17992mod agent_stream_blocked_tests {
17993    use super::*;
17994    use gate4agent_types::{
17995        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17996        ProviderSource,
17997    };
17998
17999    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18000        ProviderSource {
18001            family,
18002            binding: AdapterBinding::new(
18003                AdapterId::new(adapter).unwrap(),
18004                "fixture/v1",
18005                AdapterVerification::SyntheticFixture,
18006            )
18007            .unwrap(),
18008        }
18009    }
18010
18011    fn provider_control_event_at(
18012        family: AdapterFamily,
18013        adapter: &str,
18014        source_sequence: u64,
18015        provider_event: ProviderEvent,
18016    ) -> ControlEvent {
18017        ControlEvent {
18018            sequence: 41,
18019            command_id: None,
18020            instance_id: AgentInstanceId(7),
18021            generation: SessionGeneration(3),
18022            event: ControlEventKind::ProviderEvent {
18023                sequence: 8,
18024                source: provider_source(family, adapter),
18025                source_sequence,
18026                event: provider_event,
18027            },
18028        }
18029    }
18030
18031    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18032        provider_control_event_at(family, "fixture", 9, provider_event)
18033    }
18034
18035    fn denied_host_request(
18036        by: ProviderHostDecisionAuthority,
18037        reason: Option<String>,
18038    ) -> ControlEvent {
18039        provider_control_event_at(
18040            AdapterFamily::Acp,
18041            "claude-code",
18042            2,
18043            ProviderEvent::HostRequestObserved {
18044                method: "terminal/create".to_owned(),
18045                params_json: String::new(),
18046                decision: ProviderHostRequestDecision::Denied { by },
18047                outcome: ProviderHostRequestOutcome::Executed,
18048                reason,
18049            },
18050        )
18051    }
18052
18053    fn blocked(
18054        event: &ControlEvent,
18055    ) -> (
18056        Option<String>,
18057        String,
18058        BlockAuthorityV1,
18059        Option<String>,
18060        String,
18061        Option<String>,
18062    ) {
18063        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18064        chunk.validate().expect("minted chunk must validate");
18065        let AgentStreamChunkKindV1::Blocked {
18066            correlation_id,
18067            tool_class,
18068            authority,
18069            reason_kind,
18070            reason,
18071            help,
18072        } = chunk.kind
18073        else {
18074            panic!("expected AgentStreamChunkKindV1::Blocked");
18075        };
18076        (correlation_id, tool_class, authority, reason_kind, reason, help)
18077    }
18078
18079    /// Pins the fleet and every adapter each member declares: the fleet is
18080    /// exactly what `active_registry` says, and no member declares a hook
18081    /// adapter (lifecycle hooks are retired).
18082    #[test]
18083    fn the_enabled_provider_matrix_is_exact() {
18084        let registry = active_registry().unwrap();
18085        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
18086        assert_eq!(
18087            providers
18088                .iter()
18089                .map(|contract| contract.provider.as_str())
18090                .collect::<Vec<_>>(),
18091            // `shell` is the plain PTY provider (operator terminals and smoke tests).
18092            vec!["shell", "claude", "codex", "grok", "kimi"],
18093        );
18094        assert!(adapters.iter().all(|contract| {
18095            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18096        }));
18097    }
18098
18099    /// A gate-denied host request, WITH its reason text carried all the way
18100    /// from the ACP host, mints a `Blocked` chunk naming the gate.
18101    #[test]
18102    fn gate_denied_host_request_mints_a_blocked_chunk() {
18103        let gate_text =
18104            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18105        let event = denied_host_request(
18106            ProviderHostDecisionAuthority::Gate,
18107            Some(gate_text.to_owned()),
18108        );
18109        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18110        assert_eq!(chunk.source_sequence, 2);
18111        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18112        assert_eq!(authority, BlockAuthorityV1::HostGate);
18113        assert_eq!(reason, gate_text);
18114        assert_eq!(correlation_id, None);
18115        assert_eq!(reason_kind, None);
18116        assert_eq!(help, None);
18117        assert_eq!(tool_class, "Write");
18118    }
18119
18120    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.";
18121
18122    /// The owner's classifier sample, verbatim, arriving as a
18123    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
18124    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
18125    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
18126    /// and the guidance tail as `help`.
18127    #[test]
18128    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18129        let event = provider_control_event(
18130            AdapterFamily::Acp,
18131            ProviderEvent::ToolCompleted {
18132                id: "toolu_9".to_owned(),
18133                output: CLASSIFIER_SAMPLE.to_owned(),
18134                is_error: true,
18135                duration_ms: None,
18136                agent_id: None,
18137                non_execution_kind: Some("permission-rule".to_owned()),
18138            },
18139        );
18140        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18141        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18142        assert_eq!(reason, "Blocked by classifier");
18143        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18144        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18145        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18146    }
18147
18148    /// A `session/prompt` RPC error with `data.codexErrorInfo:
18149    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
18150    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
18151    /// vendor code as `reason_kind`.
18152    #[test]
18153    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18154        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.";
18155        let event = provider_control_event(
18156            AdapterFamily::Acp,
18157            ProviderEvent::SessionEnded {
18158                result: "JSON-RPC error -32603: Internal error".to_owned(),
18159                cost_usd: None,
18160                is_error: true,
18161                stop_reason: Some(ProviderStopReason::ProviderError {
18162                    code: -32603,
18163                    message: message.to_owned(),
18164                    vendor_code: Some("usageLimitExceeded".to_owned()),
18165                }),
18166            },
18167        );
18168        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18169        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18170        assert_eq!(reason, message);
18171        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18172        assert_eq!(tool_class, "Session");
18173    }
18174
18175    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
18176    /// `authority: ProviderRefusal`.
18177    #[test]
18178    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18179        let event = provider_control_event(
18180            AdapterFamily::Acp,
18181            ProviderEvent::SessionEnded {
18182                result: "refusal".to_owned(),
18183                cost_usd: None,
18184                is_error: true,
18185                stop_reason: Some(ProviderStopReason::Refusal),
18186            },
18187        );
18188        let (_, _, authority, _, _, _) = blocked(&event);
18189        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18190    }
18191
18192    /// A failed tool completion with no typed `non_execution_kind` -- or one
18193    /// that only says the turn stopped -- is a plain execution failure, never a
18194    /// block; nor is an agent's own narrative claiming one.
18195    #[test]
18196    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18197        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18198            let event = provider_control_event(
18199                AdapterFamily::Acp,
18200                ProviderEvent::ToolCompleted {
18201                    id: "toolu_1".to_owned(),
18202                    output: "command exited with status 1".to_owned(),
18203                    is_error: true,
18204                    duration_ms: Some(12),
18205                    agent_id: None,
18206                    non_execution_kind,
18207                },
18208            );
18209            assert!(agent_stream_chunk(&event).is_none());
18210        }
18211        let narrative = provider_control_event(
18212            AdapterFamily::Acp,
18213            ProviderEvent::ToolCompleted {
18214                id: "toolu_2".to_owned(),
18215                output: "the harness blocked the write, approval needed".to_owned(),
18216                is_error: false,
18217                duration_ms: Some(3),
18218                agent_id: None,
18219                non_execution_kind: None,
18220            },
18221        );
18222        assert!(agent_stream_chunk(&narrative).is_none());
18223    }
18224
18225    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
18226    /// execution outcome: a granted request that failed while running was
18227    /// still authorized.
18228    #[test]
18229    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18230        for (decision, outcome) in [
18231            (
18232                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18233                ProviderHostRequestOutcome::Executed,
18234            ),
18235            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18236            (
18237                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18238                ProviderHostRequestOutcome::Failed {
18239                    error: "terminal/create spawn failed: os error 3".to_owned(),
18240                },
18241            ),
18242        ] {
18243            let event = provider_control_event_at(
18244                AdapterFamily::Acp,
18245                "claude-code",
18246                3,
18247                ProviderEvent::HostRequestObserved {
18248                    method: "fs/read_text_file".to_owned(),
18249                    params_json: String::new(),
18250                    decision: decision.clone(),
18251                    outcome,
18252                    reason: None,
18253                },
18254            );
18255            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18256        }
18257    }
18258
18259    /// A deferred `session/request_permission` resolved by an operator or an
18260    /// expired deadline carries no reason text -- the chunk falls back to a
18261    /// categorical sentence naming the authority rather than inventing detail.
18262    #[test]
18263    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18264        for (by, expected_authority, expected_reason) in [
18265            (
18266                ProviderHostDecisionAuthority::Operator,
18267                BlockAuthorityV1::Operator,
18268                "declined by the operator",
18269            ),
18270            (
18271                ProviderHostDecisionAuthority::DeadlinePolicy,
18272                BlockAuthorityV1::HostDeadline,
18273                "denied by host policy after the operator deadline expired",
18274            ),
18275        ] {
18276            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18277            assert_eq!(authority, expected_authority);
18278            assert_eq!(reason, expected_reason);
18279        }
18280    }
18281
18282    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
18283    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
18284    /// outright rather than truncating.
18285    #[test]
18286    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18287        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18288        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18289            ProviderHostDecisionAuthority::Policy,
18290            Some(oversized),
18291        ));
18292        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18293    }
18294}
18295
18296#[cfg(test)]
18297mod standalone_capability_platform_tests {
18298    use super::*;
18299
18300    #[test]
18301    fn standalone_workspace_capability_requires_windows_identity_guards() {
18302        let capabilities = baseline_capabilities().unwrap();
18303        let advertised = capabilities.iter().any(|capability| {
18304            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18305        });
18306        assert_eq!(advertised, cfg!(windows));
18307
18308        let request = NodeRequest::CreateStandaloneWorkspace {
18309            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18310            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18311            initial_branch: None,
18312        };
18313        assert_eq!(
18314            request_uses_unnegotiated_capability(&request, &capabilities),
18315            !cfg!(windows),
18316        );
18317    }
18318}
18319
18320
18321#[cfg(test)]
18322mod station_knobs_resolve_tests {
18323    use super::*;
18324    use gate4agent_catalog::EnvMutation;
18325    use gate4agent_runtime_native::{
18326        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18327        NativeLaunchProfileId,
18328    };
18329    use std::ffi::OsString;
18330    use std::sync::Arc;
18331
18332    struct EmptyEnvironmentResolver;
18333
18334    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18335        fn resolve_child_environment(
18336            &self,
18337        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18338            Ok(vec![EnvMutation {
18339                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18340                value: None,
18341            }])
18342        }
18343    }
18344
18345    fn agent(value: &str) -> AgentId {
18346        AgentId::new(value).unwrap()
18347    }
18348
18349    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18350        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18351        let profile_revision =
18352            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18353        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18354            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18355            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18356            provider: agent("claude"),
18357            mode: SessionMode::Pty,
18358            terminal_size: gate4agent_types::TerminalSize {
18359                rows: 24,
18360                columns: 80,
18361            },
18362            prompt: None,
18363            bundle_id: None,
18364            context_id: None,
18365            environment_profile_id: Some(profile_id.clone()),
18366        };
18367        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18368        let catalog = active_registry().unwrap();
18369        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18370        let control = runtime.native_launch_profile_control();
18371        let node_profile = NodeEnvironmentProfile::new(
18372            profile_id.clone(),
18373            profile_revision,
18374            agent("claude"),
18375            [NativeLaunchProfile::new(
18376                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18377                agent("claude"),
18378                TransportKind::Pty,
18379                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18380                Arc::new(EmptyEnvironmentResolver),
18381            )
18382            .unwrap()],
18383        )
18384        .unwrap();
18385        let (binding, native_profiles, _materialization) = node_profile.into_parts();
18386        for native_profile in native_profiles {
18387            runtime.upsert_native_launch_profile(native_profile).unwrap();
18388        }
18389        let workspace = WorkspaceConfig::new(
18390            WorkspaceId::new("primary").unwrap(),
18391            std::env::current_dir().unwrap(),
18392        )
18393        .unwrap();
18394        let shared = NodeShared::new_with_incarnation(
18395            handle,
18396            "fixture-token".to_owned(),
18397            NodeId::new("node-station-knobs-test").unwrap(),
18398            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18399            vec![workspace],
18400            vec![agent("claude")],
18401            ProviderRuntimeStatuses::default(),
18402            None,
18403            Vec::new(),
18404            Vec::new(),
18405            spawn_profiles,
18406            Some(control),
18407            None,
18408            None,
18409            None,
18410            Vec::new(),
18411            Vec::new(),
18412            Vec::new(),
18413            Vec::new(),
18414            None,
18415            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18416        );
18417        shared
18418            .environment_profiles
18419            .write()
18420            .unwrap_or_else(|poisoned| poisoned.into_inner())
18421            .insert(binding.id.clone(), binding);
18422        (shared, spawn_defaults)
18423    }
18424
18425    /// Empty-default catalog refuses unknown network_allowlist ids.
18426    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
18427    #[test]
18428    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18429        let (shared, defaults) = station_knobs_fixture();
18430        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18431        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18432        let spec = crate::protocol::SpawnSpec {
18433            target: crate::protocol::SpawnTarget {
18434                node_id: shared.node_id.clone(),
18435                workspace_id: WorkspaceId::new("primary").unwrap(),
18436                worktree_id: None,
18437            },
18438            profile_id: defaults.profile_id.clone(),
18439            expected_profile_revision: defaults.revision.clone(),
18440            overrides: crate::protocol::SpawnOverrides {
18441                network_allowlist: Some(unknown),
18442                browser_profile_id: Some(browser),
18443                ..crate::protocol::SpawnOverrides::default()
18444            },
18445            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18446            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18447                "station-knobs-unknown-allowlist",
18448            )
18449            .unwrap(),
18450            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18451        };
18452        let resolved = spec.resolve(&defaults).unwrap();
18453        let failure = shared
18454            .resolve_environment_profile(&resolved)
18455            .expect_err("unknown allowlist must refuse");
18456        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18457    }
18458
18459    /// Registered network_allowlist (+ browser_profile_id when feature off)
18460    /// echo onto the env-profile receipt. Empty catalog still allows omit
18461    /// (None). Empty/whitespace refuse is type-level. Feature-off
18462    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
18463    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
18464    #[test]
18465    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18466        let (shared, defaults) = station_knobs_fixture();
18467        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18468        #[cfg(not(feature = "dig2-station-probe"))]
18469        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18470        shared
18471            .network_allowlist_catalog
18472            .write()
18473            .unwrap_or_else(|poisoned| poisoned.into_inner())
18474            .insert_id(allowlist.clone())
18475            .unwrap();
18476        let spec = crate::protocol::SpawnSpec {
18477            target: crate::protocol::SpawnTarget {
18478                node_id: shared.node_id.clone(),
18479                workspace_id: WorkspaceId::new("primary").unwrap(),
18480                worktree_id: None,
18481            },
18482            profile_id: defaults.profile_id.clone(),
18483            expected_profile_revision: defaults.revision.clone(),
18484            overrides: crate::protocol::SpawnOverrides {
18485                network_allowlist: Some(allowlist.clone()),
18486                #[cfg(not(feature = "dig2-station-probe"))]
18487                browser_profile_id: Some(browser.clone()),
18488                ..crate::protocol::SpawnOverrides::default()
18489            },
18490            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18491            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18492                .unwrap(),
18493            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18494        };
18495        let resolved = spec.resolve(&defaults).unwrap();
18496        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18497        #[cfg(not(feature = "dig2-station-probe"))]
18498        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18499        let receipt = shared
18500            .resolve_environment_profile(&resolved)
18501            .expect("resolve ok")
18502            .expect("env profile present");
18503        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18504        #[cfg(not(feature = "dig2-station-probe"))]
18505        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18506        #[cfg(feature = "dig2-station-probe")]
18507        assert_eq!(receipt.browser_profile_id, None);
18508        assert_eq!(receipt.profile_id.as_str(), "local-claude");
18509
18510        let omitted = crate::protocol::SpawnSpec {
18511            overrides: crate::protocol::SpawnOverrides::default(),
18512            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18513                .unwrap(),
18514            ..spec
18515        };
18516        let resolved_omit = omitted.resolve(&defaults).unwrap();
18517        let bare = shared
18518            .resolve_environment_profile(&resolved_omit)
18519            .expect("resolve ok")
18520            .expect("env profile present");
18521        assert_eq!(bare.network_allowlist, None);
18522        assert_eq!(bare.browser_profile_id, None);
18523    }
18524
18525    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
18526    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
18527    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
18528    /// unit-tested separately (probe refuses before lease on Linux).
18529    #[cfg(feature = "dig2-station-probe")]
18530    #[test]
18531    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18532        let (shared, defaults) = station_knobs_fixture();
18533        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18534        let spec = crate::protocol::SpawnSpec {
18535            target: crate::protocol::SpawnTarget {
18536                node_id: shared.node_id.clone(),
18537                workspace_id: WorkspaceId::new("primary").unwrap(),
18538                worktree_id: None,
18539            },
18540            profile_id: defaults.profile_id.clone(),
18541            expected_profile_revision: defaults.revision.clone(),
18542            overrides: crate::protocol::SpawnOverrides {
18543                browser_profile_id: Some(browser),
18544                ..crate::protocol::SpawnOverrides::default()
18545            },
18546            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18547            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18548                "station-knobs-probe-refuse",
18549            )
18550            .unwrap(),
18551            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18552        };
18553        let resolved = spec.resolve(&defaults).unwrap();
18554        let failure = shared
18555            .resolve_environment_profile(&resolved)
18556            .expect_err("browser_profile with failing probe must refuse");
18557        #[cfg(windows)]
18558        {
18559            assert!(
18560                matches!(
18561                    failure.code,
18562                    NodeFailureCode::BrowserStationUnreachable
18563                        | NodeFailureCode::BrowserStationProbeUnavailable
18564                ),
18565                "unexpected code {:?}",
18566                failure.code
18567            );
18568        }
18569        #[cfg(not(windows))]
18570        {
18571            assert_eq!(
18572                failure.code,
18573                NodeFailureCode::BrowserStationProbeUnavailable
18574            );
18575        }
18576    }
18577
18578    /// Feature on: omitting browser_profile_id still resolves (no probe).
18579    #[cfg(feature = "dig2-station-probe")]
18580    #[test]
18581    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18582        let (shared, defaults) = station_knobs_fixture();
18583        let spec = crate::protocol::SpawnSpec {
18584            target: crate::protocol::SpawnTarget {
18585                node_id: shared.node_id.clone(),
18586                workspace_id: WorkspaceId::new("primary").unwrap(),
18587                worktree_id: None,
18588            },
18589            profile_id: defaults.profile_id.clone(),
18590            expected_profile_revision: defaults.revision.clone(),
18591            overrides: crate::protocol::SpawnOverrides::default(),
18592            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18593            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18594                "station-knobs-probe-omit",
18595            )
18596            .unwrap(),
18597            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18598        };
18599        let resolved = spec.resolve(&defaults).unwrap();
18600        let receipt = shared
18601            .resolve_environment_profile(&resolved)
18602            .expect("omit browser_profile must not probe")
18603            .expect("env profile present");
18604        assert_eq!(receipt.browser_profile_id, None);
18605    }
18606
18607    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
18608    /// refuses a second holder for the same opaque profile id; release on
18609    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
18610    #[cfg(feature = "dig2-station-probe")]
18611    #[test]
18612    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18613        let (shared, _defaults) = station_knobs_fixture();
18614        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18615        let workspace_id = WorkspaceId::new("primary").unwrap();
18616        let address_a = SessionAddress {
18617            workspace_id: workspace_id.clone(),
18618            session: SessionKey {
18619                instance_id: AgentInstanceId(101),
18620                generation: SessionGeneration(1),
18621            },
18622        };
18623        let address_b = SessionAddress {
18624            workspace_id: workspace_id.clone(),
18625            session: SessionKey {
18626                instance_id: AgentInstanceId(102),
18627                generation: SessionGeneration(1),
18628            },
18629        };
18630        let guard_a = crate::dig2_station_lease::acquire_lease(
18631            &shared.browser_station_leases,
18632            profile.clone(),
18633            address_a.session.instance_id,
18634            1,
18635        )
18636        .expect("first lease");
18637        let busy = crate::dig2_station_lease::acquire_lease(
18638            &shared.browser_station_leases,
18639            profile.clone(),
18640            address_b.session.instance_id,
18641            2,
18642        )
18643        .expect_err("second holder must refuse");
18644        assert!(matches!(
18645            busy,
18646            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18647        ));
18648        // Simulate a bound session so remove_binding clears the lease.
18649        shared.bind_session_with_policy(
18650            &address_a,
18651            ProviderRuntimePolicy::raw_pty(),
18652            None,
18653        );
18654        guard_a.retain();
18655        assert!(shared.remove_binding(&address_a).is_some());
18656        crate::dig2_station_lease::acquire_lease(
18657            &shared.browser_station_leases,
18658            profile,
18659            address_b.session.instance_id,
18660            3,
18661        )
18662        .expect("reacquire after clear")
18663        .retain();
18664        assert!(
18665            crate::dig2_station_lease::release_lease_for_holder(
18666                &shared.browser_station_leases,
18667                address_b.session.instance_id,
18668            )
18669        );
18670    }
18671
18672    /// Feature-off: no browser_station_leases field; stub echo documented in
18673    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
18674    #[cfg(not(feature = "dig2-station-probe"))]
18675    #[test]
18676    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18677        let (shared, defaults) = station_knobs_fixture();
18678        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18679        let spec = crate::protocol::SpawnSpec {
18680            target: crate::protocol::SpawnTarget {
18681                node_id: shared.node_id.clone(),
18682                workspace_id: WorkspaceId::new("primary").unwrap(),
18683                worktree_id: None,
18684            },
18685            profile_id: defaults.profile_id.clone(),
18686            expected_profile_revision: defaults.revision.clone(),
18687            overrides: crate::protocol::SpawnOverrides {
18688                browser_profile_id: Some(browser.clone()),
18689                ..crate::protocol::SpawnOverrides::default()
18690            },
18691            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18692            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18693                .unwrap(),
18694            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18695        };
18696        let resolved = spec.resolve(&defaults).unwrap();
18697        let receipt = shared
18698            .resolve_environment_profile(&resolved)
18699            .expect("stub resolve")
18700            .expect("env profile");
18701        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18702    }
18703
18704
18705    /// Startup file loader registers ids into the empty-default catalog.
18706    /// Feature-off dig2 probe stays stub. Never secrets on C2.
18707    #[test]
18708    fn network_allowlist_catalog_loads_from_temp_file() {
18709        let unique = std::time::SystemTime::now()
18710            .duration_since(std::time::UNIX_EPOCH)
18711            .unwrap()
18712            .as_nanos();
18713        let path = std::env::temp_dir().join(format!(
18714            "gate4agent-node-allowlist-config-{}-{unique}.txt",
18715            std::process::id(),
18716        ));
18717        std::fs::write(
18718            &path,
18719            "# opaque station allowlist ids\negress-default\nlab-egress\n",
18720        )
18721        .unwrap();
18722        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18723            .expect("temp catalog file must load");
18724        let _ = std::fs::remove_file(&path);
18725        assert_eq!(loaded.len(), 2);
18726        let (shared, defaults) = station_knobs_fixture();
18727        {
18728            let mut catalog = shared
18729                .network_allowlist_catalog
18730                .write()
18731                .unwrap_or_else(|poisoned| poisoned.into_inner());
18732            *catalog = loaded;
18733        }
18734        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18735        let spec = crate::protocol::SpawnSpec {
18736            target: crate::protocol::SpawnTarget {
18737                node_id: shared.node_id.clone(),
18738                workspace_id: WorkspaceId::new("primary").unwrap(),
18739                worktree_id: None,
18740            },
18741            profile_id: defaults.profile_id.clone(),
18742            expected_profile_revision: defaults.revision.clone(),
18743            overrides: crate::protocol::SpawnOverrides {
18744                network_allowlist: Some(allowlist.clone()),
18745                ..crate::protocol::SpawnOverrides::default()
18746            },
18747            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18748            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18749                "station-knobs-catalog-file",
18750            )
18751            .unwrap(),
18752            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18753        };
18754        let resolved = spec.resolve(&defaults).unwrap();
18755        let receipt = shared
18756            .resolve_environment_profile(&resolved)
18757            .expect("registered via file load")
18758            .expect("env profile present");
18759        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18760    }
18761
18762    /// Snapshot launch inventory echoes registered catalog ids (opaque).
18763    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
18764    #[test]
18765    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18766        let (shared, _defaults) = station_knobs_fixture();
18767        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18768        shared
18769            .network_allowlist_catalog
18770            .write()
18771            .unwrap_or_else(|poisoned| poisoned.into_inner())
18772            .insert_id(allowlist.clone())
18773            .unwrap();
18774        let snapshot = shared.snapshot();
18775        let inventory = snapshot
18776            .launch_inventory
18777            .expect("launch inventory present");
18778        let ids = inventory
18779            .network_allowlists
18780            .expect("non-empty catalog surfaces ids");
18781        assert_eq!(ids, vec![allowlist]);
18782    }
18783
18784    /// Track B: registered id with provider_native codex_network_access refuses
18785    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
18786    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
18787    #[test]
18788    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18789        let (shared, defaults) = station_knobs_fixture();
18790        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18791        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18792            id: allowlist.clone(),
18793            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18794                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18795                peer: "127.0.0.1:443".to_owned(),
18796            }],
18797            provider_native: Some(
18798                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18799                    codex_network_access: Some(true),
18800                },
18801            ),
18802        };
18803        shared
18804            .network_allowlist_catalog
18805            .write()
18806            .unwrap_or_else(|poisoned| poisoned.into_inner())
18807            .insert(entry)
18808            .unwrap();
18809        let spec = crate::protocol::SpawnSpec {
18810            target: crate::protocol::SpawnTarget {
18811                node_id: shared.node_id.clone(),
18812                workspace_id: WorkspaceId::new("primary").unwrap(),
18813                worktree_id: None,
18814            },
18815            profile_id: defaults.profile_id.clone(),
18816            expected_profile_revision: defaults.revision.clone(),
18817            overrides: crate::protocol::SpawnOverrides {
18818                network_allowlist: Some(allowlist),
18819                ..crate::protocol::SpawnOverrides::default()
18820            },
18821            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18822            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18823                "station-knobs-unsupported-mapping",
18824            )
18825            .unwrap(),
18826            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18827        };
18828        let resolved = spec.resolve(&defaults).unwrap();
18829        assert_eq!(resolved.provider.as_str(), "claude");
18830        let failure = shared
18831            .resolve_environment_profile(&resolved)
18832            .expect_err("claude + codex_network_access must refuse");
18833        assert_eq!(
18834            failure.code,
18835            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18836        );
18837    }
18838
18839    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
18840    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
18841    /// Never secrets on C2.
18842    #[test]
18843    fn resolve_codex_network_access_overlay_moderate_only() {
18844        let (shared, defaults) = station_knobs_fixture();
18845        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18846        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18847            id: allowlist.clone(),
18848            permits: Vec::new(),
18849            provider_native: Some(
18850                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18851                    codex_network_access: Some(true),
18852                },
18853            ),
18854        };
18855        shared
18856            .network_allowlist_catalog
18857            .write()
18858            .unwrap_or_else(|poisoned| poisoned.into_inner())
18859            .insert(entry)
18860            .unwrap();
18861
18862        let overlay = shared
18863            .resolve_codex_network_access_overlay_args(
18864                &agent("codex"),
18865                SessionMode::Pty,
18866                ApprovalLevel::Moderate,
18867                Some(&allowlist),
18868            )
18869            .expect("Moderate Codex maps network_access");
18870        assert_eq!(
18871            overlay,
18872            vec![
18873                std::ffi::OsString::from("-c"),
18874                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18875            ]
18876        );
18877
18878        let acp = shared
18879            .resolve_codex_network_access_overlay_args(
18880                &agent("codex"),
18881                SessionMode::Acp,
18882                ApprovalLevel::Moderate,
18883                Some(&allowlist),
18884            )
18885            .unwrap();
18886        assert_eq!(acp, overlay);
18887
18888        let inline = shared
18889            .resolve_codex_network_access_overlay_args(
18890                &agent("codex"),
18891                SessionMode::Inline,
18892                ApprovalLevel::Moderate,
18893                Some(&allowlist),
18894            )
18895            .unwrap_err();
18896        assert_eq!(
18897            inline.code,
18898            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18899        );
18900
18901        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18902            let spec = crate::protocol::SpawnSpec {
18903                target: crate::protocol::SpawnTarget {
18904                    node_id: shared.node_id.clone(),
18905                    workspace_id: WorkspaceId::new("primary").unwrap(),
18906                    worktree_id: None,
18907                },
18908                profile_id: defaults.profile_id.clone(),
18909                expected_profile_revision: defaults.revision.clone(),
18910                overrides: crate::protocol::SpawnOverrides {
18911                    provider: crate::protocol::SpawnOverride::Set {
18912                        value: agent("codex"),
18913                    },
18914                    approval_level: Some(level),
18915                    network_allowlist: Some(allowlist.clone()),
18916                    ..crate::protocol::SpawnOverrides::default()
18917                },
18918                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18919                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18920                    "codex-net-refuse-{}",
18921                    match level {
18922                        ApprovalLevel::ReadOnly => "ro",
18923                        ApprovalLevel::FullAuto => "fa",
18924                        _ => "other",
18925                    }
18926                ))
18927                .unwrap(),
18928                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18929            };
18930            let resolved = spec.resolve(&defaults).unwrap();
18931            let failure = shared
18932                .resolve_environment_profile(&resolved)
18933                .expect_err("RO/FullAuto must refuse");
18934            assert_eq!(
18935                failure.code,
18936                NodeFailureCode::UnsupportedNetworkAllowlistMapping
18937            );
18938        }
18939    }
18940
18941    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
18942    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
18943    /// Never secrets on C2; does not invent provider_native Claude keys.
18944    #[test]
18945    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18946        let (shared, _defaults) = station_knobs_fixture();
18947        let allowlist =
18948            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18949        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18950            id: allowlist.clone(),
18951            permits: vec![
18952                crate::network_allowlist_catalog::NetworkPermitSketch {
18953                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18954                    peer: "github.com:443".to_owned(),
18955                },
18956                crate::network_allowlist_catalog::NetworkPermitSketch {
18957                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18958                    peer: "*.npmjs.org".to_owned(),
18959                },
18960            ],
18961            provider_native: None,
18962        };
18963        shared
18964            .network_allowlist_catalog
18965            .write()
18966            .unwrap_or_else(|poisoned| poisoned.into_inner())
18967            .insert(entry)
18968            .unwrap();
18969
18970        let unique = std::time::SystemTime::now()
18971            .duration_since(std::time::UNIX_EPOCH)
18972            .unwrap()
18973            .as_nanos();
18974        let provider_home = std::env::temp_dir().join(format!(
18975            "gate4agent-claude-provider-home-{}-{unique}",
18976            std::process::id(),
18977        ));
18978        std::fs::create_dir_all(&provider_home).unwrap();
18979
18980        let overlay = shared
18981            .resolve_network_allowlist_overlay_args(
18982                &agent("claude"),
18983                SessionMode::Pty,
18984                ApprovalLevel::Moderate,
18985                Some(&allowlist),
18986                Some(provider_home.as_path()),
18987            )
18988            .expect("Claude PTY settings overlay on unix");
18989        let settings_path = provider_home.join(
18990            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18991        );
18992        assert_eq!(
18993            overlay,
18994            vec![
18995                std::ffi::OsString::from("--settings"),
18996                std::ffi::OsString::from(settings_path.as_os_str()),
18997            ]
18998        );
18999        let body = std::fs::read_to_string(&settings_path).unwrap();
19000        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19001        assert_eq!(value["sandbox"]["enabled"], true);
19002        assert_eq!(
19003            value["sandbox"]["network"]["allowedDomains"],
19004            serde_json::json!(["github.com", "*.npmjs.org"])
19005        );
19006        assert!(settings_path.starts_with(&provider_home));
19007
19008        let acp = shared
19009            .resolve_network_allowlist_overlay_args(
19010                &agent("claude"),
19011                SessionMode::Acp,
19012                ApprovalLevel::Moderate,
19013                Some(&allowlist),
19014                Some(provider_home.as_path()),
19015            )
19016            .expect_err("Claude ACP must refuse settings overlay");
19017        assert_eq!(
19018            acp.code,
19019            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19020        );
19021
19022        let no_home = shared
19023            .resolve_network_allowlist_overlay_args(
19024                &agent("claude"),
19025                SessionMode::Pty,
19026                ApprovalLevel::Moderate,
19027                Some(&allowlist),
19028                None,
19029            )
19030            .expect_err("Claude overlay requires ProviderHome");
19031        assert_eq!(
19032            no_home.code,
19033            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19034        );
19035
19036        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19037        shared
19038            .network_allowlist_catalog
19039            .write()
19040            .unwrap_or_else(|poisoned| poisoned.into_inner())
19041            .insert_id(bare.clone())
19042            .unwrap();
19043        let empty = shared
19044            .resolve_network_allowlist_overlay_args(
19045                &agent("claude"),
19046                SessionMode::Pty,
19047                ApprovalLevel::Moderate,
19048                Some(&bare),
19049                Some(provider_home.as_path()),
19050            )
19051            .unwrap();
19052        assert!(empty.is_empty());
19053
19054        let _ = std::fs::remove_dir_all(&provider_home);
19055    }
19056
19057    /// Kimi / Grok with permits stay without invented argv overlays.
19058    #[test]
19059    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19060        let (shared, _defaults) = station_knobs_fixture();
19061        let allowlist =
19062            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19063        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19064            id: allowlist.clone(),
19065            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19066                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19067                peer: "example.com:443".to_owned(),
19068            }],
19069            provider_native: None,
19070        };
19071        shared
19072            .network_allowlist_catalog
19073            .write()
19074            .unwrap_or_else(|poisoned| poisoned.into_inner())
19075            .insert(entry)
19076            .unwrap();
19077        for provider in ["kimi", "grok"] {
19078            let overlay = shared
19079                .resolve_network_allowlist_overlay_args(
19080                    &agent(provider),
19081                    SessionMode::Pty,
19082                    ApprovalLevel::Moderate,
19083                    Some(&allowlist),
19084                    None,
19085                )
19086                .unwrap();
19087            assert!(
19088                overlay.is_empty(),
19089                "{provider} must not invent network argv from permits"
19090            );
19091        }
19092    }
19093
19094    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
19095    #[test]
19096    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19097        let unique = std::time::SystemTime::now()
19098            .duration_since(std::time::UNIX_EPOCH)
19099            .unwrap()
19100            .as_nanos();
19101        let path = std::env::temp_dir().join(format!(
19102            "gate4agent-node-allowlist-json-{}-{unique}.json",
19103            std::process::id(),
19104        ));
19105        std::fs::write(
19106            &path,
19107            r#"{
19108              "schema_version": 2,
19109              "entries": [
19110                {
19111                  "id": "egress-default",
19112                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19113                },
19114                { "id": "lab-egress" }
19115              ]
19116            }"#,
19117        )
19118        .unwrap();
19119        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19120            .expect("json v2 catalog must load");
19121        let _ = std::fs::remove_file(&path);
19122        assert_eq!(loaded.len(), 2);
19123        let entry = loaded
19124            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19125            .unwrap();
19126        assert_eq!(entry.permits.len(), 1);
19127        let (shared, defaults) = station_knobs_fixture();
19128        {
19129            let mut catalog = shared
19130                .network_allowlist_catalog
19131                .write()
19132                .unwrap_or_else(|poisoned| poisoned.into_inner());
19133            *catalog = loaded;
19134        }
19135        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19136        let spec = crate::protocol::SpawnSpec {
19137            target: crate::protocol::SpawnTarget {
19138                node_id: shared.node_id.clone(),
19139                workspace_id: WorkspaceId::new("primary").unwrap(),
19140                worktree_id: None,
19141            },
19142            profile_id: defaults.profile_id.clone(),
19143            expected_profile_revision: defaults.revision.clone(),
19144            overrides: crate::protocol::SpawnOverrides {
19145                network_allowlist: Some(allowlist.clone()),
19146                ..crate::protocol::SpawnOverrides::default()
19147            },
19148            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19149            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19150                "station-knobs-catalog-json",
19151            )
19152            .unwrap(),
19153            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19154        };
19155        let resolved = spec.resolve(&defaults).unwrap();
19156        let receipt = shared
19157            .resolve_environment_profile(&resolved)
19158            .expect("permits-only entry ok for claude")
19159            .expect("env profile present");
19160        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19161        let inventory = shared
19162            .snapshot()
19163            .launch_inventory
19164            .expect("launch inventory present");
19165        let ids = inventory.network_allowlists.expect("ids surface");
19166        assert_eq!(
19167            ids,
19168            vec![
19169                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19170                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19171            ]
19172        );
19173    }
19174
19175
19176}
19177
19178
19179#[cfg(test)]
19180mod workspace_environment_collision_tests {
19181    use super::*;
19182    use crate::session_environment::{
19183        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19184    };
19185    use gate4agent_catalog::EnvMutation;
19186    use gate4agent_runtime_native::{
19187        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19188        NativeLaunchProfileId,
19189    };
19190    use std::ffi::OsString;
19191    use std::sync::Arc;
19192
19193    struct EmptyEnvironmentResolver;
19194
19195    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19196        fn resolve_child_environment(
19197            &self,
19198        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19199            Ok(vec![EnvMutation {
19200                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19201                value: None,
19202            }])
19203        }
19204    }
19205
19206    fn agent(value: &str) -> AgentId {
19207        AgentId::new(value).unwrap()
19208    }
19209
19210    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
19211    fn claude_config_dir_collision_fixture() -> (
19212        NodeShared,
19213        ResolvedEnvironmentProfileReceipt,
19214        ResolvedEnvironmentProfileReceipt,
19215    ) {
19216        let catalog = active_registry().unwrap();
19217        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19218        let control = runtime.native_launch_profile_control();
19219        let workspace = WorkspaceConfig::new(
19220            WorkspaceId::new("primary").unwrap(),
19221            std::env::current_dir().unwrap(),
19222        )
19223        .unwrap();
19224        let shared = NodeShared::new_with_incarnation(
19225            handle,
19226            "fixture-token".to_owned(),
19227            NodeId::new("node-env-collision-test").unwrap(),
19228            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19229            vec![workspace],
19230            vec![agent("claude")],
19231            ProviderRuntimeStatuses::default(),
19232            None,
19233            Vec::new(),
19234            Vec::new(),
19235            SpawnProfileRegistry::default(),
19236            Some(control),
19237            None,
19238            None,
19239            None,
19240            Vec::new(),
19241            Vec::new(),
19242            Vec::new(),
19243            Vec::new(),
19244            None,
19245            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19246        );
19247
19248        let mut receipts = Vec::new();
19249        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19250            let profile_id =
19251                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19252            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19253                "claude-config-dir-{suffix}-r1"
19254            ))
19255            .unwrap();
19256            let materialization = NodeSessionMaterializationProfile::new(
19257                Vec::new(),
19258                vec![NodeSessionPathBinding::new(
19259                    "CLAUDE_CONFIG_DIR",
19260                    NodeSessionPathClass::ProviderHome,
19261                )
19262                .unwrap()],
19263                Vec::new(),
19264            )
19265            .unwrap();
19266            let node_profile = NodeEnvironmentProfile::new_with_materialization(
19267                profile_id.clone(),
19268                profile_revision.clone(),
19269                agent("claude"),
19270                [NativeLaunchProfile::new(
19271                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19272                        .unwrap(),
19273                    agent("claude"),
19274                    TransportKind::Pty,
19275                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19276                    Arc::new(EmptyEnvironmentResolver),
19277                )
19278                .unwrap()],
19279                Some(materialization),
19280            )
19281            .unwrap();
19282            let (binding, native_profiles, materialization) = node_profile.into_parts();
19283            for native_profile in native_profiles {
19284                runtime.upsert_native_launch_profile(native_profile).unwrap();
19285            }
19286            shared
19287                .environment_profiles
19288                .write()
19289                .unwrap_or_else(|poisoned| poisoned.into_inner())
19290                .insert(binding.id.clone(), binding);
19291            shared
19292                .environment_materialization_profiles
19293                .write()
19294                .unwrap_or_else(|poisoned| poisoned.into_inner())
19295                .insert(profile_id.clone(), materialization.unwrap());
19296            receipts.push(ResolvedEnvironmentProfileReceipt {
19297                profile_id,
19298                profile_revision,
19299                network_allowlist: None,
19300                browser_profile_id: None,
19301            });
19302        }
19303        let profile_b = receipts.pop().unwrap();
19304        let profile_a = receipts.pop().unwrap();
19305        (shared, profile_a, profile_b)
19306    }
19307
19308    #[test]
19309    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19310        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19311        let primary = WorkspaceId::new("primary").unwrap();
19312        let worktree = WorkspaceId::new("worktree-b").unwrap();
19313        let address = SessionAddress {
19314            workspace_id: primary.clone(),
19315            session: SessionKey {
19316                instance_id: AgentInstanceId(41),
19317                generation: SessionGeneration(1),
19318            },
19319        };
19320        shared.bind_session_with_policy(
19321            &address,
19322            ProviderRuntimePolicy::raw_pty(),
19323            Some(profile_a.clone()),
19324        );
19325
19326        let refused = shared
19327            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19328            .unwrap_err();
19329        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19330
19331        // Some vs None on the same cwd is also a collision.
19332        let refused_none = shared
19333            .ensure_workspace_environment_profile_compatible(&primary, None)
19334            .unwrap_err();
19335        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19336
19337        // Same profile on the same workspace remains allowed.
19338        shared
19339            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19340            .unwrap();
19341
19342        // Distinct workspace id (managed-worktree parallel path) allows profile B.
19343        shared
19344            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19345            .unwrap();
19346    }
19347
19348    #[tokio::test]
19349    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19350        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19351        let primary = WorkspaceId::new("primary").unwrap();
19352        let address = SessionAddress {
19353            workspace_id: primary.clone(),
19354            session: SessionKey {
19355                instance_id: AgentInstanceId(41),
19356                generation: SessionGeneration(1),
19357            },
19358        };
19359        shared.bind_session_with_policy(
19360            &address,
19361            ProviderRuntimePolicy::raw_pty(),
19362            Some(profile_a),
19363        );
19364
19365        let failure = shared
19366            .spawn_session_with_deadline(
19367                primary,
19368                agent("claude"),
19369                SessionMode::Pty,
19370                gate4agent_types::TerminalSize {
19371                    rows: 24,
19372                    columns: 80,
19373                },
19374                None,
19375                Some(profile_b),
19376                None,
19377                None,
19378                None,
19379                None,
19380                SpawnRecordPolicy::ProviderIdentityOnly,
19381                Some(Instant::now() + Duration::from_secs(30)),
19382                &[],
19383                None,
19384                ApprovalLevel::default(),
19385                None,
19386                None,
19387            )
19388            .await
19389            .unwrap_err();
19390        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19391        // Guard runs before instance id mint / bind.
19392        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19393        assert!(shared.handle.snapshot().sessions.is_empty());
19394    }
19395}
19396
19397#[cfg(test)]
19398mod spawn_characterization_tests {
19399    use super::*;
19400    use crate::session_environment::{
19401        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19402        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19403        NodeSessionPathClass,
19404    };
19405    use gate4agent_catalog::EnvMutation;
19406    use gate4agent_runtime_native::{
19407        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19408        NativeLaunchProfile, NativeLaunchProfileId,
19409    };
19410    use std::ffi::OsString;
19411    use std::sync::Arc;
19412
19413    struct EmptyEnvironmentResolver;
19414
19415    struct FixtureSecretResolver {
19416        deny: Arc<AtomicBool>,
19417    }
19418
19419    impl NodeSecretResolver for FixtureSecretResolver {
19420        fn resolve(
19421            &self,
19422            _reference: &NodeSecretReference,
19423        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19424            if self.deny.load(Ordering::Acquire) {
19425                Err(NodeSecretResolveError::Denied)
19426            } else {
19427                NodeSecretValue::text("fixture-secret")
19428                    .map_err(|_| NodeSecretResolveError::Unavailable)
19429            }
19430        }
19431    }
19432
19433    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19434        fn resolve_child_environment(
19435            &self,
19436        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19437            Ok(vec![EnvMutation {
19438                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19439                value: None,
19440            }])
19441        }
19442    }
19443
19444    fn agent(value: &str) -> AgentId {
19445        AgentId::new(value).unwrap()
19446    }
19447
19448    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19449        let unique = std::time::SystemTime::now()
19450            .duration_since(std::time::UNIX_EPOCH)
19451            .unwrap()
19452            .as_nanos();
19453        std::env::temp_dir().join(format!(
19454            "gate4agent-node-{label}-{}-{unique}",
19455            std::process::id(),
19456        ))
19457    }
19458
19459    fn materialization_test_shared(
19460        deny_secret: bool,
19461    ) -> (
19462        NodeShared,
19463        NativeRuntime,
19464        ResolvedEnvironmentProfileReceipt,
19465        PathBuf,
19466        Arc<AtomicBool>,
19467    ) {
19468        let workspace = WorkspaceConfig::new(
19469            WorkspaceId::new("primary").unwrap(),
19470            std::env::current_dir().unwrap(),
19471        )
19472        .unwrap();
19473        materialization_test_shared_with_workspace(deny_secret, workspace)
19474    }
19475
19476    fn materialization_test_shared_with_workspace(
19477        deny_secret: bool,
19478        workspace: WorkspaceConfig,
19479    ) -> (
19480        NodeShared,
19481        NativeRuntime,
19482        ResolvedEnvironmentProfileReceipt,
19483        PathBuf,
19484        Arc<AtomicBool>,
19485    ) {
19486        let root = temporary_workspace_root(if deny_secret {
19487            "materialization-denied"
19488        } else {
19489            "materialization-ready"
19490        });
19491        std::fs::create_dir_all(&root).unwrap();
19492        let materialization_root = root.join("session-environments");
19493        let deny = Arc::new(AtomicBool::new(deny_secret));
19494        let resolver = Arc::new(FixtureSecretResolver {
19495            deny: Arc::clone(&deny),
19496        });
19497        let materializer = SessionEnvironmentMaterializer::new(
19498            materialization_root,
19499            resolver,
19500        )
19501        .unwrap();
19502        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19503        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19504            "materialized-claude-r1",
19505        )
19506        .unwrap();
19507        let materialization = NodeSessionMaterializationProfile::new(
19508            vec![NodeSessionEnvironmentMutation::SetSecret {
19509                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19510                reference: NodeSecretReference::new("fixture-token").unwrap(),
19511            }],
19512            vec![NodeSessionPathBinding::new(
19513                "GATE4AGENT_PROVIDER_HOME",
19514                NodeSessionPathClass::ProviderHome,
19515            )
19516            .unwrap()],
19517            vec![NodeSessionFile::secret(
19518                NodeSessionPathClass::Config,
19519                "auth/token",
19520                NodeSecretReference::new("fixture-token").unwrap(),
19521            )
19522            .unwrap()],
19523        )
19524        .unwrap();
19525        let catalog = active_registry().unwrap();
19526        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19527        let control = runtime.native_launch_profile_control();
19528        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19529            profile_id.clone(),
19530            profile_revision.clone(),
19531            agent("claude"),
19532            [NativeLaunchProfile::new(
19533                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19534                agent("claude"),
19535                TransportKind::Pty,
19536                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19537                Arc::new(EmptyEnvironmentResolver),
19538            )
19539            .unwrap()],
19540            Some(materialization),
19541        )
19542        .unwrap();
19543        let (binding, native_profiles, materialization) = node_profile.into_parts();
19544        for native_profile in native_profiles {
19545            runtime.upsert_native_launch_profile(native_profile).unwrap();
19546        }
19547        let shared = NodeShared::new_with_incarnation(
19548            handle,
19549            "fixture-token".to_owned(),
19550            NodeId::new("node-materialization-test").unwrap(),
19551            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19552            vec![workspace],
19553            vec![agent("claude")],
19554            ProviderRuntimeStatuses::default(),
19555            None,
19556            Vec::new(),
19557            Vec::new(),
19558            SpawnProfileRegistry::default(),
19559            Some(control),
19560            None,
19561            Some(materializer),
19562            None,
19563            Vec::new(),
19564            Vec::new(),
19565            Vec::new(),
19566            Vec::new(),
19567            None,
19568            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19569        );
19570        shared
19571            .environment_profiles
19572            .write()
19573            .unwrap_or_else(|poisoned| poisoned.into_inner())
19574            .insert(binding.id.clone(), binding);
19575        shared
19576            .environment_materialization_profiles
19577            .write()
19578            .unwrap_or_else(|poisoned| poisoned.into_inner())
19579            .insert(profile_id.clone(), materialization.unwrap());
19580        (
19581            shared,
19582            runtime,
19583            ResolvedEnvironmentProfileReceipt {
19584                profile_id,
19585                profile_revision,
19586                network_allowlist: None,
19587                browser_profile_id: None,
19588            },
19589            root,
19590            deny,
19591        )
19592    }
19593
19594
19595    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
19596
19597    async fn characterization_spawn(
19598        shared: &NodeShared,
19599        mode: SessionMode,
19600        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19601        required: &[ProviderRuntimeCapability],
19602        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19603    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19604        shared
19605            .spawn_session_with_deadline(
19606                WorkspaceId::new("primary").unwrap(),
19607                agent("claude"),
19608                mode,
19609                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19610                None,
19611                environment_profile,
19612                None,
19613                None,
19614                None,
19615                Some(ProviderRuntimePolicy::raw_pty()),
19616                SpawnRecordPolicy::ProviderIdentityOnly,
19617                Some(Instant::now() + Duration::from_secs(30)),
19618                required,
19619                harness_mcp,
19620                ApprovalLevel::default(),
19621                None,
19622                None,
19623            )
19624            .await
19625    }
19626
19627    fn assert_no_session_state(shared: &NodeShared) {
19628        assert!(shared.handle.snapshot().sessions.is_empty());
19629        assert!(shared
19630            .session_bindings
19631            .lock()
19632            .unwrap_or_else(|poisoned| poisoned.into_inner())
19633            .is_empty());
19634        assert!(shared
19635            .materializations
19636            .lock()
19637            .unwrap_or_else(|poisoned| poisoned.into_inner())
19638            .is_empty());
19639    }
19640
19641    #[tokio::test]
19642    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19643        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19644        let error = characterization_spawn(
19645            &shared,
19646            SessionMode::Pty,
19647            None,
19648            &[ProviderRuntimeCapability::SemanticReadiness],
19649            None,
19650        )
19651        .await
19652        .unwrap_err();
19653        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19654        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19655        assert_no_session_state(&shared);
19656        drop(shared);
19657        drop(runtime);
19658        std::fs::remove_dir_all(root).unwrap();
19659    }
19660
19661    #[tokio::test]
19662    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19663        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19664        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19665            .await
19666            .unwrap_err();
19667        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19668        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19669        assert_no_session_state(&shared);
19670        drop(shared);
19671        drop(runtime);
19672        std::fs::remove_dir_all(root).unwrap();
19673    }
19674
19675    #[test]
19676    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19677        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19678        let address = SessionAddress {
19679            workspace_id: WorkspaceId::new("primary").unwrap(),
19680            session: SessionKey {
19681                instance_id: AgentInstanceId(77),
19682                generation: SessionGeneration(1),
19683            },
19684        };
19685        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19686        let removed = shared.remove_binding(&address).expect("binding removed");
19687        assert!(removed.environment_profile.is_some());
19688        assert!(shared.remove_binding(&address).is_none());
19689        assert_no_session_state(&shared);
19690        drop(shared);
19691        drop(runtime);
19692        std::fs::remove_dir_all(root).unwrap();
19693    }
19694
19695    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19696        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19697        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19698        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19699            .expect("review current test executable as fixture helper");
19700        let registry = HarnessMcpProxyRegistry::new(helper);
19701        let spec = crate::protocol::SpawnSpec {
19702            target: crate::protocol::SpawnTarget {
19703                node_id: NodeId::new("node-characterization").unwrap(),
19704                workspace_id: WorkspaceId::new("primary").unwrap(),
19705                worktree_id: None,
19706            },
19707            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19708            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19709            overrides: crate::protocol::SpawnOverrides::default(),
19710            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19711            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19712            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19713        };
19714        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19715        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19716        let launch = HarnessMcpLaunchV1 {
19717            server_name: "fixture-harness".to_owned(),
19718            args: vec!["--session-proxy".to_owned()],
19719            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19720            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19721            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19722            trace: None,
19723            scrub_env: Vec::new(),
19724        };
19725        let expires = unix_time_ms().saturating_add(30_000);
19726        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19727        registry
19728            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19729            .unwrap()
19730    }
19731
19732    #[tokio::test]
19733    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19734        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19735        let prepared = characterization_prepared_harness("claude").await;
19736        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19737            .await
19738            .unwrap_err();
19739        // Inline is stopped by runtime admission before the harness check runs.
19740        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19741        let mismatched = characterization_prepared_harness("codex").await;
19742        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19743            .await
19744            .unwrap_err();
19745        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19746        assert_no_session_state(&shared);
19747        drop(shared);
19748        drop(runtime);
19749        std::fs::remove_dir_all(root).unwrap();
19750    }
19751
19752    #[tokio::test]
19753    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19754        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19755        shared.native_launch_profile_control = None;
19756        let prepared = characterization_prepared_harness("claude").await;
19757        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19758            .await
19759            .unwrap_err();
19760        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19761        assert_no_session_state(&shared);
19762        drop(shared);
19763        drop(runtime);
19764        std::fs::remove_dir_all(root).unwrap();
19765    }
19766}
19767
19768#[cfg(all(test, windows))]
19769mod tests {
19770    use super::*;
19771    use gate4agent_types::ProviderActivity;
19772    use crate::session_environment::{
19773        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19774        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19775        NodeSessionPathClass,
19776    };
19777    use gate4agent_catalog::EnvMutation;
19778    use gate4agent_runtime_native::{
19779        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19780        NativeLaunchProfile, NativeLaunchProfileId,
19781    };
19782    use std::ffi::OsString;
19783    use std::sync::Arc;
19784
19785    struct EmptyEnvironmentResolver;
19786
19787    struct FixtureSecretResolver {
19788        deny: Arc<AtomicBool>,
19789    }
19790
19791    impl NodeSecretResolver for FixtureSecretResolver {
19792        fn resolve(
19793            &self,
19794            _reference: &NodeSecretReference,
19795        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19796            if self.deny.load(Ordering::Acquire) {
19797                Err(NodeSecretResolveError::Denied)
19798            } else {
19799                NodeSecretValue::text("fixture-secret")
19800                    .map_err(|_| NodeSecretResolveError::Unavailable)
19801            }
19802        }
19803    }
19804
19805    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19806        fn resolve_child_environment(
19807            &self,
19808        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19809            Ok(vec![EnvMutation {
19810                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19811                value: None,
19812            }])
19813        }
19814    }
19815
19816    fn agent(value: &str) -> AgentId {
19817        AgentId::new(value).unwrap()
19818    }
19819
19820    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19821        let catalog = active_registry().unwrap();
19822        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19823        let workspace = WorkspaceConfig::new(
19824            WorkspaceId::new("primary").unwrap(),
19825            std::env::current_dir().unwrap(),
19826        )
19827        .unwrap()
19828        .with_worktree_service_mode(mode);
19829        NodeShared::new(
19830            handle,
19831            "fixture-token".to_owned(),
19832            NodeId::new("node-terminal-test").unwrap(),
19833            vec![workspace],
19834            vec![agent("claude")],
19835        )
19836    }
19837
19838    fn terminal_test_shared() -> NodeShared {
19839        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19840    }
19841
19842    #[cfg(feature = "fixture")]
19843    #[test]
19844    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19845        let probe = SpawnManagedWorktreeV2FailureProbe::default();
19846        assert_eq!(probe.latest_code(), None);
19847
19848        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19849        assert_eq!(
19850            probe.latest_code(),
19851            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19852        );
19853
19854        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19855        assert_eq!(
19856            probe.latest_code(),
19857            Some(NodeFailureCode::SpawnDeadlineExceeded),
19858        );
19859
19860        probe.record(None);
19861        assert_eq!(probe.latest_code(), None);
19862    }
19863
19864    #[test]
19865    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19866        let source = temporary_workspace_root("launch-inventory-source");
19867        let allocation = temporary_workspace_root("launch-inventory-allocation");
19868        std::fs::create_dir_all(&source).unwrap();
19869        std::fs::create_dir_all(&allocation).unwrap();
19870        let workspace_id = WorkspaceId::new("primary").unwrap();
19871        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19872            .unwrap()
19873            .with_worktree_service_mode(WorktreeServiceMode::Managed);
19874        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19875            workspace = workspace
19876                .with_managed_worktree_profile(
19877                    ManagedWorktreeProfile::new(
19878                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19879                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19880                        &allocation,
19881                        "gate4agent",
19882                        "HEAD",
19883                        ManagedWorktreeRetention::RemoveWhenReleased,
19884                    )
19885                    .unwrap(),
19886                )
19887                .unwrap();
19888        }
19889        let overflow = workspace.clone().with_managed_worktree_profile(
19890            ManagedWorktreeProfile::new(
19891                WorktreeProfileId::new("overflow").unwrap(),
19892                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19893                &allocation,
19894                "gate4agent",
19895                "HEAD",
19896                ManagedWorktreeRetention::Retain,
19897            )
19898            .unwrap(),
19899        );
19900        assert!(matches!(
19901            overflow,
19902            Err(NodeServerError::ManagedWorktreeProfileCapacity {
19903                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19904                ..
19905            }),
19906        ));
19907
19908        let catalog = active_registry().unwrap();
19909        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19910        let shared = NodeShared::new(
19911            handle,
19912            "fixture-token".to_owned(),
19913            NodeId::new("node-launch-inventory").unwrap(),
19914            vec![workspace],
19915            vec![agent("claude")],
19916        );
19917        let snapshot = shared.snapshot();
19918        let launch = snapshot.launch_inventory.as_ref().unwrap();
19919        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19920        assert!(launch.bundles.as_ref().unwrap().is_empty());
19921        let profiles = snapshot.workspaces[0]
19922            .managed_worktree_profiles
19923            .as_ref()
19924            .unwrap();
19925        assert_eq!(
19926            snapshot.workspaces[0].worktree_service_mode,
19927            Some(WorktreeServiceMode::Managed),
19928        );
19929        assert_eq!(
19930            profiles.profiles.len(),
19931            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19932        );
19933        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19934        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19935            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19936        }
19937        let legacy = snapshot_for_wire(
19938            &shared,
19939            true,
19940            true,
19941            false,
19942            false,
19943            true,
19944            false,
19945            true,
19946            false,
19947            true,
19948        );
19949        assert!(legacy.launch_inventory.is_none());
19950        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19951
19952        drop(shared);
19953        std::fs::remove_dir_all(source).unwrap();
19954        std::fs::remove_dir_all(allocation).unwrap();
19955    }
19956
19957    #[test]
19958    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19959        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19960            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19961        }));
19962        let source = gate4agent_types::ProviderSource {
19963            family: AdapterFamily::PtySemantic,
19964            binding: AdapterBinding::new(
19965                gate4agent_types::AdapterId::new("claude").unwrap(),
19966                "fixture/v1",
19967                gate4agent_types::AdapterVerification::SyntheticFixture,
19968            )
19969            .unwrap(),
19970        };
19971        let provider = ProviderSnapshot {
19972            sequence: 17,
19973            session: Some(ProviderSessionIdentity {
19974                key: ProviderSessionKey::SessionId,
19975                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19976                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
19977            }),
19978            model: Some("PRIVATE_MODEL".to_owned()),
19979            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
19980            completed_turns: 3,
19981            usage: gate4agent_types::TokenUsage {
19982                input_tokens: 10,
19983                output_tokens: 20,
19984                cache_read_tokens: 30,
19985                cache_write_tokens: 40,
19986                reasoning_tokens: 50,
19987                context_window: Some(200_000),
19988            },
19989            lead_activity: ProviderActivity::WaitingForInput,
19990            activity: ProviderActivity::WaitingForInput,
19991            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
19992            active_tools: (0..9)
19993                .map(|index| gate4agent_types::ActiveProviderTool {
19994                    id: format!("private-tool-id-{index}"),
19995                    name: if index == 0 {
19996                        format!("  Read\u{0000}{}", "x".repeat(80))
19997                    } else {
19998                        format!("Tool-{index}")
19999                    },
20000                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20001                })
20002                .collect(),
20003            interactions: vec![gate4agent_types::ProviderInteraction {
20004                id: gate4agent_types::ProviderInteractionId(17),
20005                source: source.clone(),
20006                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20007                interaction_kind: ProviderInteractionKind::Approval,
20008                tool_name: " Approve\u{0007}Tool ".to_owned(),
20009                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20010                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20011                resume_lead_activity: Some(ProviderActivity::Working),
20012                status: ProviderInteractionStatus::Pending,
20013            }],
20014            subagents: vec![gate4agent_types::ProviderSubagent {
20015                source: source.clone(),
20016                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20017                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20018                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20019            }],
20020            sources: vec![gate4agent_types::ProviderSourceCursor {
20021                source,
20022                sequence: 17,
20023                gap_count: 2,
20024                stale: true,
20025            }],
20026            last_event: Some(ProviderEvent::Error {
20027                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20028            }),
20029            gap_count: 2,
20030            stale: true,
20031        };
20032        let address = terminal_address(3);
20033        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20034        assert_eq!(entry.address, address);
20035        assert_eq!(entry.progress.provider_sequence, 17);
20036        assert_eq!(entry.progress.active_tool_count, 9);
20037        assert_eq!(
20038            entry.progress.active_tool_labels.len(),
20039            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20040        );
20041        assert_eq!(entry.progress.subagent_count, 1);
20042        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20043        assert!(entry.progress.truncated);
20044        let encoded = serde_json::to_string(&entry).unwrap();
20045        for forbidden in [
20046            "PRIVATE_PROVIDER_SESSION",
20047            "PRIVATE_PROVIDER_PATH",
20048            "PRIVATE_MODEL",
20049            "PRIVATE_TOOL_INVENTORY",
20050            "PRIVATE_CURRENT_PROMPT",
20051            "PRIVATE_TOOL_INPUT_JSON",
20052            "PRIVATE_PROVIDER_REQUEST",
20053            "PRIVATE_INTERACTION_PROMPT",
20054            "PRIVATE_INTERACTION_AGENT",
20055            "PRIVATE_SUBAGENT_ID",
20056            "PRIVATE_SUBAGENT_TYPE",
20057            "PRIVATE_SUBAGENT_DESCRIPTION",
20058            "PRIVATE_PROVIDER_ERROR",
20059            "input_json",
20060            "current_prompt",
20061            "session_id",
20062        ] {
20063            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20064        }
20065
20066        let snapshot = NodeSnapshot {
20067            node_id: NodeId::new("node-progress-test").unwrap(),
20068            enabled_providers: vec![agent("claude")],
20069            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20070            workspaces: Vec::new(),
20071            session_records: Vec::new(),
20072            managed_worktrees: Vec::new(),
20073            launch_inventory: None,
20074            agent_progress: vec![entry],
20075        };
20076        let mut legacy_reply = ResponseEnvelope {
20077            request_id: 1,
20078            result: Ok(NodeResponse::Snapshot {
20079                event_sequence: 0,
20080                controller: None,
20081                snapshot: snapshot.clone(),
20082            }),
20083        };
20084        project_response_without_agent_progress(&mut legacy_reply);
20085        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20086            panic!("agent progress projection changed response kind");
20087        };
20088        assert!(legacy.agent_progress.is_empty());
20089        assert_eq!(snapshot.agent_progress.len(), 1);
20090    }
20091
20092    fn terminal_address(generation: u64) -> SessionAddress {
20093        SessionAddress {
20094            workspace_id: WorkspaceId::new("primary").unwrap(),
20095            session: SessionKey {
20096                instance_id: AgentInstanceId(41),
20097                generation: SessionGeneration(generation),
20098            },
20099        }
20100    }
20101
20102    fn spawn_spec_fixture() -> SpawnSpec {
20103        SpawnSpec {
20104            target: crate::protocol::SpawnTarget {
20105                node_id: NodeId::new("node-terminal-test").unwrap(),
20106                workspace_id: WorkspaceId::new("primary").unwrap(),
20107                worktree_id: None,
20108            },
20109            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20110            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20111                .unwrap(),
20112            overrides: crate::protocol::SpawnOverrides::default(),
20113            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20114            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20115            required_capabilities: SpawnRequiredCapabilities::default(),
20116        }
20117    }
20118
20119    fn environment_profile_test_shared(
20120    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20121        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20122        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20123            "local-claude-r1",
20124        )
20125        .unwrap();
20126        let spawn_profiles = SpawnProfileRegistry::new([
20127            crate::protocol::SpawnProfileDefaults {
20128                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20129                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20130                provider: agent("claude"),
20131                mode: SessionMode::Pty,
20132                terminal_size: gate4agent_types::TerminalSize {
20133                    rows: 24,
20134                    columns: 80,
20135                },
20136                prompt: None,
20137                bundle_id: None,
20138                context_id: None,
20139                environment_profile_id: Some(profile_id.clone()),
20140            },
20141        ])
20142        .unwrap();
20143        let catalog = active_registry().unwrap();
20144        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20145        let control = runtime.native_launch_profile_control();
20146        let node_profile = NodeEnvironmentProfile::new(
20147            profile_id.clone(),
20148            profile_revision.clone(),
20149            agent("claude"),
20150            [NativeLaunchProfile::new(
20151                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20152                agent("claude"),
20153                TransportKind::Pty,
20154                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20155                Arc::new(EmptyEnvironmentResolver),
20156            )
20157            .unwrap()],
20158        )
20159        .unwrap();
20160        let (binding, native_profiles, _materialization) = node_profile.into_parts();
20161        for native_profile in native_profiles {
20162            runtime.upsert_native_launch_profile(native_profile).unwrap();
20163        }
20164        let workspace = WorkspaceConfig::new(
20165            WorkspaceId::new("primary").unwrap(),
20166            std::env::current_dir().unwrap(),
20167        )
20168        .unwrap();
20169        let shared = NodeShared::new_with_incarnation(
20170            handle,
20171            "fixture-token".to_owned(),
20172            NodeId::new("node-terminal-test").unwrap(),
20173            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20174            vec![workspace],
20175            vec![agent("claude")],
20176            ProviderRuntimeStatuses::default(),
20177            None,
20178            Vec::new(),
20179            Vec::new(),
20180            spawn_profiles,
20181            Some(control),
20182            None,
20183            None,
20184            None,
20185            Vec::new(),
20186            Vec::new(),
20187            Vec::new(),
20188            Vec::new(),
20189            None,
20190            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20191        );
20192        shared
20193            .environment_profiles
20194            .write()
20195            .unwrap_or_else(|poisoned| poisoned.into_inner())
20196            .insert(binding.id.clone(), binding);
20197        (
20198            shared,
20199            runtime,
20200            ResolvedEnvironmentProfileReceipt {
20201                profile_id,
20202                profile_revision,
20203                network_allowlist: None,
20204                browser_profile_id: None,
20205            },
20206        )
20207    }
20208
20209    fn materialization_test_shared(
20210        deny_secret: bool,
20211    ) -> (
20212        NodeShared,
20213        NativeRuntime,
20214        ResolvedEnvironmentProfileReceipt,
20215        PathBuf,
20216        Arc<AtomicBool>,
20217    ) {
20218        let workspace = WorkspaceConfig::new(
20219            WorkspaceId::new("primary").unwrap(),
20220            std::env::current_dir().unwrap(),
20221        )
20222        .unwrap();
20223        materialization_test_shared_with_workspace(deny_secret, workspace)
20224    }
20225
20226    fn materialization_test_shared_with_workspace(
20227        deny_secret: bool,
20228        workspace: WorkspaceConfig,
20229    ) -> (
20230        NodeShared,
20231        NativeRuntime,
20232        ResolvedEnvironmentProfileReceipt,
20233        PathBuf,
20234        Arc<AtomicBool>,
20235    ) {
20236        let root = temporary_workspace_root(if deny_secret {
20237            "materialization-denied"
20238        } else {
20239            "materialization-ready"
20240        });
20241        std::fs::create_dir_all(&root).unwrap();
20242        let materialization_root = root.join("session-environments");
20243        let deny = Arc::new(AtomicBool::new(deny_secret));
20244        let resolver = Arc::new(FixtureSecretResolver {
20245            deny: Arc::clone(&deny),
20246        });
20247        let materializer = SessionEnvironmentMaterializer::new(
20248            materialization_root,
20249            resolver,
20250        )
20251        .unwrap();
20252        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20253        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20254            "materialized-claude-r1",
20255        )
20256        .unwrap();
20257        let materialization = NodeSessionMaterializationProfile::new(
20258            vec![NodeSessionEnvironmentMutation::SetSecret {
20259                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20260                reference: NodeSecretReference::new("fixture-token").unwrap(),
20261            }],
20262            vec![NodeSessionPathBinding::new(
20263                "GATE4AGENT_PROVIDER_HOME",
20264                NodeSessionPathClass::ProviderHome,
20265            )
20266            .unwrap()],
20267            vec![NodeSessionFile::secret(
20268                NodeSessionPathClass::Config,
20269                "auth/token",
20270                NodeSecretReference::new("fixture-token").unwrap(),
20271            )
20272            .unwrap()],
20273        )
20274        .unwrap();
20275        let catalog = active_registry().unwrap();
20276        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20277        let control = runtime.native_launch_profile_control();
20278        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20279            profile_id.clone(),
20280            profile_revision.clone(),
20281            agent("claude"),
20282            [NativeLaunchProfile::new(
20283                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20284                agent("claude"),
20285                TransportKind::Pty,
20286                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20287                Arc::new(EmptyEnvironmentResolver),
20288            )
20289            .unwrap()],
20290            Some(materialization),
20291        )
20292        .unwrap();
20293        let (binding, native_profiles, materialization) = node_profile.into_parts();
20294        for native_profile in native_profiles {
20295            runtime.upsert_native_launch_profile(native_profile).unwrap();
20296        }
20297        let shared = NodeShared::new_with_incarnation(
20298            handle,
20299            "fixture-token".to_owned(),
20300            NodeId::new("node-materialization-test").unwrap(),
20301            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20302            vec![workspace],
20303            vec![agent("claude")],
20304            ProviderRuntimeStatuses::default(),
20305            None,
20306            Vec::new(),
20307            Vec::new(),
20308            SpawnProfileRegistry::default(),
20309            Some(control),
20310            None,
20311            Some(materializer),
20312            None,
20313            Vec::new(),
20314            Vec::new(),
20315            Vec::new(),
20316            Vec::new(),
20317            None,
20318            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20319        );
20320        shared
20321            .environment_profiles
20322            .write()
20323            .unwrap_or_else(|poisoned| poisoned.into_inner())
20324            .insert(binding.id.clone(), binding);
20325        shared
20326            .environment_materialization_profiles
20327            .write()
20328            .unwrap_or_else(|poisoned| poisoned.into_inner())
20329            .insert(profile_id.clone(), materialization.unwrap());
20330        (
20331            shared,
20332            runtime,
20333            ResolvedEnvironmentProfileReceipt {
20334                profile_id,
20335                profile_revision,
20336                network_allowlist: None,
20337                browser_profile_id: None,
20338            },
20339            root,
20340            deny,
20341        )
20342    }
20343
20344    #[cfg(feature = "fixture")]
20345    #[derive(Clone, Copy)]
20346    enum CodexHomeProfile {
20347        Missing,
20348        WrongClass,
20349        Exact,
20350    }
20351
20352    #[cfg(feature = "fixture")]
20353    fn codex_bundle_test_shared(
20354        home_profile: CodexHomeProfile,
20355    ) -> (
20356        NodeShared,
20357        NativeRuntime,
20358        ResolvedEnvironmentProfileReceipt,
20359        NodeBundle,
20360        PathBuf,
20361    ) {
20362        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20363        const CLAUDE_MANIFEST: &[u8] =
20364            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20365        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20366        const DIGEST: &str =
20367            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20368
20369        let (shared, mut runtime, _claude_receipt, root, _deny) =
20370            materialization_test_shared(false);
20371        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20372            CodexHomeProfile::Missing => "codex-home-missing",
20373            CodexHomeProfile::WrongClass => "codex-home-wrong",
20374            CodexHomeProfile::Exact => "codex-home-exact",
20375        })
20376        .unwrap();
20377        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20378            "codex-home-r1",
20379        )
20380        .unwrap();
20381        let materialization = match home_profile {
20382            CodexHomeProfile::Missing => None,
20383            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20384                Some(NodeSessionMaterializationProfile::new(
20385                    Vec::new(),
20386                    vec![NodeSessionPathBinding::new(
20387                        "CODEX_HOME",
20388                        if matches!(home_profile, CodexHomeProfile::Exact) {
20389                            NodeSessionPathClass::ProviderHome
20390                        } else {
20391                            NodeSessionPathClass::Config
20392                        },
20393                    )
20394                    .unwrap()],
20395                    Vec::new(),
20396                )
20397                .unwrap())
20398            }
20399        };
20400        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20401            profile_id.clone(),
20402            profile_revision.clone(),
20403            agent("codex"),
20404            [NativeLaunchProfile::new(
20405                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20406                agent("codex"),
20407                TransportKind::Pty,
20408                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20409                Arc::new(EmptyEnvironmentResolver),
20410            )
20411            .unwrap()],
20412            materialization,
20413        )
20414        .unwrap();
20415        let (binding, native_profiles, materialization) = node_profile.into_parts();
20416        for native_profile in native_profiles {
20417            runtime.upsert_native_launch_profile(native_profile).unwrap();
20418        }
20419        shared
20420            .environment_profiles
20421            .write()
20422            .unwrap_or_else(|poisoned| poisoned.into_inner())
20423            .insert(binding.id.clone(), binding);
20424        if let Some(materialization) = materialization {
20425            shared
20426                .environment_materialization_profiles
20427                .write()
20428                .unwrap_or_else(|poisoned| poisoned.into_inner())
20429                .insert(profile_id.clone(), materialization);
20430        }
20431
20432        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20433        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20434        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20435        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20436        std::fs::write(
20437            bundle_source.join(".claude-plugin/plugin.json"),
20438            CLAUDE_MANIFEST,
20439        )
20440        .unwrap();
20441        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20442        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20443        let bundle = NodeBundle::new(
20444            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20445            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20446            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20447            &bundle_source,
20448        )
20449        .unwrap();
20450        *shared
20451            .bundle_catalog
20452            .write()
20453            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20454            BundleCatalog::new([bundle.clone()]).unwrap();
20455        (
20456            shared,
20457            runtime,
20458            ResolvedEnvironmentProfileReceipt {
20459                profile_id,
20460                profile_revision,
20461                network_allowlist: None,
20462                browser_profile_id: None,
20463            },
20464            bundle,
20465            root,
20466        )
20467    }
20468
20469    #[cfg(feature = "fixture")]
20470    #[test]
20471    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20472        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20473            let (shared, runtime, receipt, bundle, root) =
20474                codex_bundle_test_shared(home_profile);
20475            let error = shared
20476                .bundle_layout(
20477                    &agent("codex"),
20478                    SessionMode::Pty,
20479                    Some(&receipt),
20480                    &bundle,
20481                )
20482                .unwrap_err();
20483            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20484            assert!(shared
20485                .materializations
20486                .lock()
20487                .unwrap_or_else(|poisoned| poisoned.into_inner())
20488                .is_empty());
20489            let materialization_root = root.join("session-environments");
20490            assert!(!std::fs::read_dir(&materialization_root)
20491                .unwrap()
20492                .filter_map(Result::ok)
20493                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20494            drop(shared);
20495            drop(runtime);
20496            let _ = std::fs::remove_dir_all(root);
20497        }
20498    }
20499
20500    #[cfg(feature = "fixture")]
20501    #[test]
20502    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20503        let (shared, runtime, receipt, bundle, root) =
20504            codex_bundle_test_shared(CodexHomeProfile::Exact);
20505        let address = SessionAddress {
20506            workspace_id: WorkspaceId::new("primary").unwrap(),
20507            session: SessionKey {
20508                instance_id: AgentInstanceId(310),
20509                generation: SessionGeneration(1),
20510            },
20511        };
20512        let (overlay, guard) = shared
20513            .prepare_session_materialization(
20514                &address,
20515                &agent("codex"),
20516                SessionMode::Pty,
20517                Some(&receipt),
20518                Some(&bundle.receipt()),
20519                None,
20520                None,
20521            )
20522            .unwrap()
20523            .unwrap();
20524        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20525        let ownership = shared
20526            .materializations
20527            .lock()
20528            .unwrap_or_else(|poisoned| poisoned.into_inner())
20529            .get(guard.id().unwrap())
20530            .unwrap()
20531            .clone();
20532        let (profile, _, _) = shared
20533            .materialization_profile(
20534                &agent("codex"),
20535                SessionMode::Pty,
20536                Some(&receipt),
20537                Some(&bundle.receipt()),
20538                None,
20539            )
20540            .unwrap()
20541            .unwrap();
20542        let environment = shared
20543            .session_environment_materializer
20544            .as_ref()
20545            .unwrap()
20546            .resolve_environment(&ownership, &profile)
20547            .unwrap();
20548        assert!(environment.iter().any(|mutation| {
20549            mutation.key == OsString::from("CODEX_HOME")
20550                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20551        }));
20552        assert_eq!(
20553            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20554                .unwrap(),
20555            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20556        );
20557        assert!(std::fs::read_dir(ownership.bundle_root())
20558            .unwrap()
20559            .next()
20560            .is_none());
20561        drop(guard);
20562        drop(shared);
20563        drop(runtime);
20564        let _ = std::fs::remove_dir_all(root);
20565    }
20566
20567    #[cfg(feature = "fixture")]
20568    #[test]
20569    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20570        let (shared, runtime, receipt, bundle, root) =
20571            codex_bundle_test_shared(CodexHomeProfile::Exact);
20572        let bundle_receipt = bundle.receipt();
20573        let address = SessionAddress {
20574            workspace_id: WorkspaceId::new("primary").unwrap(),
20575            session: SessionKey {
20576                instance_id: AgentInstanceId(311),
20577                generation: SessionGeneration(1),
20578            },
20579        };
20580        let (_overlay, guard) = shared
20581            .prepare_session_materialization(
20582                &address,
20583                &agent("codex"),
20584                SessionMode::Pty,
20585                Some(&receipt),
20586                Some(&bundle_receipt),
20587                None,
20588                None,
20589            )
20590            .unwrap()
20591            .unwrap();
20592        let materialization_id = guard.id().unwrap().clone();
20593        let skill_path = shared
20594            .materializations
20595            .lock()
20596            .unwrap_or_else(|poisoned| poisoned.into_inner())
20597            .get(&materialization_id)
20598            .unwrap()
20599            .provider_home()
20600            .join("skills/review-code/SKILL.md");
20601        let record_id = shared
20602            .bind_spawn_session_with_materialization(
20603                &address,
20604                agent("codex"),
20605                SessionMode::Pty,
20606                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20607                SpawnRecordPolicy::ProviderIdentityOnly,
20608                Some(receipt.clone()),
20609                Some(bundle_receipt.clone()),
20610                None,
20611                Some(materialization_id.clone()),
20612            )
20613            .unwrap()
20614            .unwrap();
20615        guard.retain();
20616        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20617
20618        let error = match shared.resolve_record_materialization(
20619                &record_id,
20620                &agent("codex"),
20621                SessionMode::Pty,
20622                Some(&receipt),
20623                Some(&bundle_receipt),
20624                None,
20625            ) {
20626                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20627                Err(error) => error,
20628            };
20629        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20630        assert_eq!(
20631            shared
20632                .materializations
20633                .lock()
20634                .unwrap_or_else(|poisoned| poisoned.into_inner())
20635                .get(&materialization_id)
20636                .unwrap()
20637                .state(),
20638            MaterializationState::RecoveryRequired,
20639        );
20640        drop(shared);
20641        drop(runtime);
20642        let _ = std::fs::remove_dir_all(root);
20643    }
20644
20645    fn bind_record_owned_materialization_fixture(
20646        shared: &NodeShared,
20647        receipt: &ResolvedEnvironmentProfileReceipt,
20648        instance_id: u64,
20649    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20650        let address = SessionAddress {
20651            workspace_id: WorkspaceId::new("primary").unwrap(),
20652            session: SessionKey {
20653                instance_id: AgentInstanceId(instance_id),
20654                generation: SessionGeneration(1),
20655            },
20656        };
20657        let (overlay, materialization_guard) = shared
20658            .prepare_session_materialization(
20659                &address,
20660                &agent("claude"),
20661                SessionMode::Pty,
20662                Some(receipt),
20663                None,
20664                None,
20665                None,
20666            )
20667            .unwrap()
20668            .unwrap();
20669        let materialization_id = materialization_guard.id().unwrap().clone();
20670        let materialization_root = shared
20671            .materializations
20672            .lock()
20673            .unwrap_or_else(|poisoned| poisoned.into_inner())
20674            .get(&materialization_id)
20675            .unwrap()
20676            .root()
20677            .to_path_buf();
20678        let selection = shared
20679            .select_environment_profile(
20680                address.session.instance_id,
20681                &agent("claude"),
20682                SessionMode::Pty,
20683                Some(receipt),
20684            )
20685            .unwrap()
20686            .unwrap();
20687        assert!(shared
20688            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20689            .unwrap()
20690            .is_none());
20691        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20692        let record_id = shared
20693            .bind_spawn_session_with_materialization(
20694                &address,
20695                agent("claude"),
20696                SessionMode::Pty,
20697                policy,
20698                SpawnRecordPolicy::ProviderIdentityOnly,
20699                Some(receipt.clone()),
20700                None,
20701                None,
20702                Some(materialization_id.clone()),
20703            )
20704            .unwrap()
20705            .unwrap();
20706        selection.retain();
20707        materialization_guard.retain();
20708        (address, record_id, materialization_id, materialization_root)
20709    }
20710
20711    #[test]
20712    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20713        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20714        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20715            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20716        }));
20717        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20718        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20719        let mut spec = spawn_spec_fixture();
20720        spec.expected_profile_revision =
20721            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20722        assert!(matches!(
20723            &spec.overrides.environment_profile_id,
20724            crate::protocol::SpawnOverride::Inherit,
20725        ));
20726        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20727            .requires_child_environment_profile_capability());
20728        assert!(request_requires_child_environment_profile(
20729            &shared,
20730            &NodeRequest::SpawnSpec { spec: spec.clone() },
20731        ));
20732
20733        let address = terminal_address(1);
20734        shared.bind_session_with_policy(
20735            &address,
20736            ProviderRuntimePolicy::raw_pty(),
20737            Some(environment_profile.clone()),
20738        );
20739        assert!(request_requires_child_environment_profile(
20740            &shared,
20741            &NodeRequest::Input {
20742                session: address.clone(),
20743                text: "x".to_owned(),
20744            },
20745        ));
20746
20747        let mut record = record("claude", "profile-record");
20748        record.active_session = Some(address.clone());
20749        record.environment_profile = Some(environment_profile.clone());
20750        let record_id = record.record_id.clone();
20751        shared.insert_record(record.clone()).unwrap();
20752        assert!(request_requires_child_environment_profile(
20753            &shared,
20754            &NodeRequest::ResumeSessionRecord {
20755                record_id,
20756                terminal_size: gate4agent_types::TerminalSize {
20757                    rows: 24,
20758                    columns: 80,
20759                },
20760                initial_prompt: None,
20761            },
20762        ));
20763
20764        let snapshot = snapshot_for_wire(
20765            &shared, false, true, true, true, false, false, false, false, true,
20766        );
20767        assert_eq!(snapshot.session_records.len(), 1);
20768        assert!(snapshot.session_records[0].environment_profile.is_none());
20769        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20770            sequence: 1,
20771            event: NodeEvent::SessionRecordUpserted { record },
20772        });
20773        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20774            panic!("record projection changed the event kind");
20775        };
20776        assert!(record.environment_profile.is_none());
20777
20778        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20779        let mut reply = ResponseEnvelope {
20780            request_id: 1,
20781            result: Ok(NodeResponse::SpawnSpecAccepted {
20782                receipt: resolved.receipt_with_environment(
20783                    shared.incarnation_id,
20784                    address,
20785                    Some(environment_profile),
20786                ),
20787            }),
20788        };
20789        project_response_without_child_environment_profile(&mut reply);
20790        assert_eq!(
20791            reply.result.unwrap_err().code,
20792            NodeFailureCode::UnsupportedCapability,
20793        );
20794    }
20795
20796    #[test]
20797    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20798        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20799        let snapshot = shared.snapshot();
20800        let profiles = snapshot
20801            .launch_inventory
20802            .as_ref()
20803            .and_then(|inventory| inventory.spawn_profiles.as_ref())
20804            .unwrap();
20805        assert_eq!(profiles.len(), 1);
20806        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20807        assert!(snapshot.requires_child_environment_profile_capability());
20808
20809        let mut legacy = snapshot.clone();
20810        clear_snapshot_child_environment_profiles(&mut legacy);
20811        assert!(legacy
20812            .launch_inventory
20813            .as_ref()
20814            .unwrap()
20815            .spawn_profiles
20816            .as_ref()
20817            .unwrap()[0]
20818            .environment_profile
20819            .is_none());
20820        assert!(!legacy.requires_child_environment_profile_capability());
20821
20822        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20823        let bindings = shared
20824            .environment_profiles
20825            .read()
20826            .unwrap_or_else(|poisoned| poisoned.into_inner())
20827            .clone();
20828        let mut provider_mismatch = bindings.clone();
20829        provider_mismatch
20830            .values_mut()
20831            .next()
20832            .unwrap()
20833            .provider = agent("codex");
20834        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20835        let mut mode_mismatch = profile;
20836        mode_mismatch.mode = SessionMode::Inline;
20837        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20838
20839        shared
20840            .environment_profiles
20841            .write()
20842            .unwrap_or_else(|poisoned| poisoned.into_inner())
20843            .clear();
20844        assert!(shared
20845            .snapshot()
20846            .launch_inventory
20847            .unwrap()
20848            .spawn_profiles
20849            .unwrap()
20850            .is_empty());
20851    }
20852
20853    #[test]
20854    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20855        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20856            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20857        }));
20858        let shared = terminal_test_shared();
20859        let bundle = ResolvedBundleReceipt {
20860            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20861            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20862            digest: crate::protocol::SpawnBundleDigest::new(format!(
20863                "sha256:{}",
20864                "0".repeat(64),
20865            ))
20866            .unwrap(),
20867        };
20868        let mut bundled = record("claude", "bundle-record");
20869        bundled.bundle = Some(bundle.clone());
20870        let record_id = bundled.record_id.clone();
20871        shared.insert_record(bundled.clone()).unwrap();
20872        assert!(request_requires_session_bundle(
20873            &shared,
20874            &NodeRequest::ForgetSessionRecord {
20875                record_id: record_id.clone(),
20876            },
20877        ));
20878
20879        let snapshot = snapshot_for_wire(
20880            &shared, false, true, true, true, true, false, false, false, true,
20881        );
20882        assert!(snapshot.session_records[0].bundle.is_none());
20883        let launch = snapshot.launch_inventory.as_ref().unwrap();
20884        assert!(launch.spawn_profiles.is_some());
20885        assert!(launch.bundles.is_none());
20886        let event = project_event_without_session_bundle(NodeEventEnvelope {
20887            sequence: 1,
20888            event: NodeEvent::SessionRecordUpserted { record: bundled },
20889        });
20890        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20891            panic!("record projection changed the event kind");
20892        };
20893        assert!(record.bundle.is_none());
20894
20895        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20896        let mut reply = ResponseEnvelope {
20897            request_id: 1,
20898            result: Ok(NodeResponse::SpawnSpecAccepted {
20899                receipt: resolved.receipt_with_materialization(
20900                    shared.incarnation_id,
20901                    terminal_address(17),
20902                    None,
20903                    Some(bundle),
20904                    None,
20905                ),
20906            }),
20907        };
20908        project_response_without_session_bundle(&mut reply);
20909        assert_eq!(
20910            reply.result.unwrap_err().code,
20911            NodeFailureCode::UnsupportedCapability,
20912        );
20913    }
20914
20915    #[test]
20916    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20917        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20918            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20919        }));
20920        let context_id = SpawnContextId::new("context-review").unwrap();
20921        let context = ResolvedContextPackReceipt {
20922            id: context_id.clone(),
20923            digest: crate::protocol::SpawnContextDigest::new(format!(
20924                "sha256:{}",
20925                "c".repeat(64),
20926            ))
20927            .unwrap(),
20928            lineage: ContextPackLineageReceipt {
20929                source_node_id: NodeId::new("node-source").unwrap(),
20930                source_session: terminal_address(1),
20931                source_provider: agent("kimi"),
20932            },
20933            source_message_count: 2,
20934            retained_message_count: 2,
20935            byte_len: 256,
20936            truncated: false,
20937        };
20938        assert!(context.is_valid());
20939        let mut bound_record = record("codex", "context-record");
20940        bound_record.context_id = Some(context_id);
20941        bound_record.context = Some(context.clone());
20942        let session = gate4agent_types::SessionSnapshot {
20943            instance_id: AgentInstanceId(91),
20944            agent_id: agent("kimi"),
20945            transport: TransportKind::Pty,
20946            generation: SessionGeneration(3),
20947            status: SessionStatus::Running,
20948            pending_operation: None,
20949            pending_input: None,
20950            process_id: Some(9001),
20951            terminal_size: None,
20952            terminal_frame: None,
20953            terminal_stale: None,
20954            session_options: None,
20955            capabilities: gate4agent_types::CapabilitySnapshot::default(),
20956            history: gate4agent_types::HistorySnapshot {
20957                pending: None,
20958                candidates: vec![HistoryCandidateSummary {
20959                    id: "candidate-opaque".to_owned(),
20960                    session_id_hint: "session-hint".to_owned(),
20961                    modified_at_unix_ms: Some(1),
20962                }],
20963                loaded_candidate_id: Some("candidate-opaque".to_owned()),
20964                loaded: Some(HistorySessionRecord {
20965                    session_id: "private-vendor-session".to_owned(),
20966                    title: Some("private title".to_owned()),
20967                    cwd: Some(r"C:\private\history-root".to_owned()),
20968                    model: Some("private-model".to_owned()),
20969                    message_count: 2,
20970                    completed_turn_count: None,
20971                    total_tokens: 19,
20972                    messages: vec![
20973                        gate4agent_types::HistoryMessageRecord {
20974                            role: gate4agent_types::HistoryMessageRole::User,
20975                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20976                        },
20977                        gate4agent_types::HistoryMessageRecord {
20978                            role: gate4agent_types::HistoryMessageRole::Assistant,
20979                            text: "private answer".to_owned(),
20980                        },
20981                    ],
20982                }),
20983                last_error: None,
20984            },
20985            resume: gate4agent_types::ResumeSnapshot::default(),
20986            foreground: gate4agent_types::ForegroundSnapshot::default(),
20987            provider: gate4agent_types::ProviderSnapshot::default(),
20988            screen_state: gate4agent_types::PtyScreenState::default(),
20989        };
20990        let original = NodeSnapshot {
20991            node_id: NodeId::new("node-terminal-test").unwrap(),
20992            enabled_providers: vec![agent("kimi"), agent("codex")],
20993            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20994            workspaces: vec![WorkspaceSnapshot {
20995                workspace_id: WorkspaceId::new("primary").unwrap(),
20996                canonical_root: opaque_windows_path(
20997                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20998                ),
20999                sessions: vec![session],
21000                worktree_service_mode: None,
21001                managed_worktree_profiles: None,
21002            }],
21003            session_records: vec![bound_record],
21004            managed_worktrees: Vec::new(),
21005            launch_inventory: None,
21006            agent_progress: Vec::new(),
21007        };
21008
21009        let mut negotiated = original.clone();
21010        project_snapshot_history_for_wire(&mut negotiated, true);
21011        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21012        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21013        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21014        assert!(!serde_json::to_string(&negotiated)
21015            .unwrap()
21016            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21017
21018        let mut legacy = original;
21019        project_snapshot_history_for_wire(&mut legacy, false);
21020        clear_snapshot_context_packs(&mut legacy);
21021        assert_eq!(
21022            legacy.workspaces[0].sessions[0].history,
21023            gate4agent_types::HistorySnapshot::default(),
21024        );
21025        assert!(legacy.session_records[0].context_id.is_none());
21026        assert!(legacy.session_records[0].context.is_none());
21027        let encoded = serde_json::to_string(&legacy).unwrap();
21028        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21029        assert!(!encoded.contains("context-review"));
21030
21031        let mut reply = ResponseEnvelope {
21032            request_id: 7,
21033            result: Ok(NodeResponse::ContextPackExported { context }),
21034        };
21035        project_response_without_context_pack(&mut reply);
21036        assert_eq!(
21037            reply.result.unwrap_err().code,
21038            NodeFailureCode::UnsupportedCapability,
21039        );
21040    }
21041
21042    #[test]
21043    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21044        let shared = terminal_test_shared();
21045        let history = HistorySessionRecord {
21046            session_id: "fixture-history".to_owned(),
21047            title: None,
21048            cwd: None,
21049            model: None,
21050            message_count: 1,
21051            completed_turn_count: None,
21052            total_tokens: 0,
21053            messages: vec![gate4agent_types::HistoryMessageRecord {
21054                role: gate4agent_types::HistoryMessageRole::User,
21055                text: "bounded handoff".to_owned(),
21056            }],
21057        };
21058        let pack = NodeContextPack::export(
21059            ContextPackLineageReceipt {
21060                source_node_id: shared.node_id.clone(),
21061                source_session: terminal_address(1),
21062                source_provider: agent("claude"),
21063            },
21064            &history,
21065        )
21066        .unwrap();
21067        let context = shared
21068            .context_catalog
21069            .write()
21070            .unwrap_or_else(|poisoned| poisoned.into_inner())
21071            .insert(pack)
21072            .unwrap();
21073        let mut retained = record("codex", "context-retained-record");
21074        retained.context_id = Some(context.id.clone());
21075        retained.context = Some(context.clone());
21076        let record_id = retained.record_id.clone();
21077        shared.insert_record(retained).unwrap();
21078        assert_eq!(
21079            shared.forget_context_pack(&context.id).unwrap_err().code,
21080            NodeFailureCode::ContextPackBusy,
21081        );
21082        assert!(shared
21083            .context_catalog
21084            .read()
21085            .unwrap_or_else(|poisoned| poisoned.into_inner())
21086            .get(&context.id)
21087            .is_some());
21088        shared
21089            .session_records
21090            .lock()
21091            .unwrap_or_else(|poisoned| poisoned.into_inner())
21092            .records
21093            .remove(&record_id);
21094
21095        let mut spec = spawn_spec_fixture();
21096        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21097            value: context.id.clone(),
21098        };
21099        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21100        let receipt = resolved.receipt_with_materialization(
21101            shared.incarnation_id,
21102            terminal_address(2),
21103            None,
21104            None,
21105            Some(context.clone()),
21106        );
21107        assert!(receipt.context_binding_is_valid());
21108        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21109
21110        shared.forget_context_pack(&context.id).unwrap();
21111        assert!(shared
21112            .context_catalog
21113            .read()
21114            .unwrap_or_else(|poisoned| poisoned.into_inner())
21115            .get(&context.id)
21116            .is_none());
21117        assert!(shared
21118            .replay_spawn_spec(&spec, Instant::now())
21119            .unwrap()
21120            .is_none());
21121    }
21122
21123    #[test]
21124    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21125        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21126        let address = terminal_address(1);
21127        let selection = shared
21128            .select_environment_profile(
21129                address.session.instance_id,
21130                &agent("claude"),
21131                SessionMode::Pty,
21132                Some(&environment_profile),
21133            )
21134            .unwrap()
21135            .unwrap();
21136        shared.bind_session_with_policy(
21137            &address,
21138            ProviderRuntimePolicy::raw_pty(),
21139            Some(environment_profile.clone()),
21140        );
21141        selection.retain();
21142        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21143        assert!(matches!(
21144            runtime.remove_native_launch_profile(&native_id),
21145            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21146        ));
21147        assert!(shared.remove_binding(&address).is_some());
21148        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21149
21150        let mismatched = ResolvedEnvironmentProfileReceipt {
21151            profile_id: environment_profile.profile_id,
21152            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21153                "local-claude-r2",
21154            )
21155            .unwrap(),
21156            network_allowlist: None,
21157            browser_profile_id: None,
21158        };
21159        let error = match shared.select_environment_profile(
21160                AgentInstanceId(42),
21161                &agent("claude"),
21162                SessionMode::Pty,
21163                Some(&mismatched),
21164            ) {
21165            Ok(_) => panic!("mismatched environment profile revision was selected"),
21166            Err(error) => error,
21167        };
21168        assert_eq!(
21169            error.code,
21170            NodeFailureCode::EnvironmentProfileBindingMismatch,
21171        );
21172        assert!(!shared
21173            .session_bindings
21174            .lock()
21175            .unwrap_or_else(|poisoned| poisoned.into_inner())
21176            .contains_key(&AgentInstanceId(42)));
21177    }
21178
21179    #[test]
21180    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21181        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21182        let address = terminal_address(1);
21183        let selection = shared
21184            .select_environment_profile(
21185                address.session.instance_id,
21186                &agent("claude"),
21187                SessionMode::Pty,
21188                Some(&environment_profile),
21189            )
21190            .unwrap()
21191            .unwrap();
21192        let overlay = NativeInstanceLaunchOverlay::new(
21193            agent("claude"),
21194            TransportKind::Pty,
21195            vec![EnvMutation {
21196                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21197                value: Some(OsString::from("fixture-secret")),
21198            }],
21199            vec![
21200                OsString::from("--plugin-dir"),
21201                std::env::current_dir().unwrap().into_os_string(),
21202            ],
21203        )
21204        .unwrap();
21205        let instance_overlay = shared
21206            .install_prepared_launch_overlay(
21207                address.session.instance_id,
21208                PreparedNativeLaunchOverlay::Instance(overlay),
21209            )
21210            .unwrap()
21211            .unwrap();
21212        let bundle = ResolvedBundleReceipt {
21213            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21214            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21215            digest: crate::protocol::SpawnBundleDigest::new(format!(
21216                "sha256:{}",
21217                "0".repeat(64),
21218            ))
21219            .unwrap(),
21220        };
21221        shared.bind_session_with_materialization(
21222            &address,
21223            ProviderRuntimePolicy::raw_pty(),
21224            Some(environment_profile),
21225            Some(bundle),
21226            None,
21227            None,
21228        );
21229        selection.retain();
21230        instance_overlay.retain();
21231
21232        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21233        assert!(matches!(
21234            runtime.remove_native_launch_profile(&native_id),
21235            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21236        ));
21237        assert!(shared.remove_binding(&address).is_some());
21238        assert!(!shared
21239            .native_launch_profile_control
21240            .as_ref()
21241            .unwrap()
21242            .clear_native_instance_launch_overlay(address.session.instance_id));
21243        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21244    }
21245
21246    #[test]
21247    fn session_environment_materialization_failure_is_pre_child_and_durable() {
21248        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21249        let address = SessionAddress {
21250            workspace_id: WorkspaceId::new("primary").unwrap(),
21251            session: SessionKey {
21252                instance_id: AgentInstanceId(201),
21253                generation: SessionGeneration(1),
21254            },
21255        };
21256        let error = match shared.prepare_session_materialization(
21257            &address,
21258            &agent("claude"),
21259            SessionMode::Pty,
21260            Some(&receipt),
21261            None,
21262            None,
21263            None,
21264        ) {
21265            Ok(_) => panic!("denied local reference unexpectedly materialized"),
21266            Err(error) => error,
21267        };
21268        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21269        assert!(shared.handle.snapshot().sessions.is_empty());
21270        assert!(shared
21271            .session_bindings
21272            .lock()
21273            .unwrap_or_else(|poisoned| poisoned.into_inner())
21274            .is_empty());
21275        assert!(shared
21276            .materializations
21277            .lock()
21278            .unwrap_or_else(|poisoned| poisoned.into_inner())
21279            .is_empty());
21280        drop(shared);
21281        drop(runtime);
21282        std::fs::remove_dir_all(root).unwrap();
21283    }
21284
21285    #[test]
21286    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21287        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21288        let address = SessionAddress {
21289            workspace_id: WorkspaceId::new("primary").unwrap(),
21290            session: SessionKey {
21291                instance_id: AgentInstanceId(202),
21292                generation: SessionGeneration(1),
21293            },
21294        };
21295        let (overlay, materialization_guard) = shared
21296            .prepare_session_materialization(
21297                &address,
21298                &agent("claude"),
21299                SessionMode::Pty,
21300                Some(&receipt),
21301                None,
21302                None,
21303                None,
21304            )
21305            .unwrap()
21306            .unwrap();
21307        let materialization_id = materialization_guard.id().unwrap().clone();
21308        let materialization_root = shared
21309            .materializations
21310            .lock()
21311            .unwrap_or_else(|poisoned| poisoned.into_inner())
21312            .get(&materialization_id)
21313            .unwrap()
21314            .root()
21315            .to_path_buf();
21316        let selection = shared
21317            .select_environment_profile(
21318                address.session.instance_id,
21319                &agent("claude"),
21320                SessionMode::Pty,
21321                Some(&receipt),
21322            )
21323            .unwrap()
21324            .unwrap();
21325        assert!(shared
21326            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21327            .unwrap()
21328            .is_none());
21329        shared.bind_session_with_materialization(
21330            &address,
21331            ProviderRuntimePolicy::raw_pty(),
21332            Some(receipt),
21333            None,
21334            None,
21335            Some(materialization_id),
21336        );
21337        selection.retain();
21338        materialization_guard.retain();
21339
21340        assert!(materialization_root.is_dir());
21341        assert!(shared
21342            .session_bindings
21343            .lock()
21344            .unwrap_or_else(|poisoned| poisoned.into_inner())
21345            .contains_key(&address.session.instance_id));
21346
21347        let removed = shared.remove_binding(&address).unwrap();
21348        shared
21349            .cleanup_session_owned_materialization(&address, &removed)
21350            .unwrap();
21351        assert!(!materialization_root.exists());
21352        assert!(shared
21353            .materializations
21354            .lock()
21355            .unwrap_or_else(|poisoned| poisoned.into_inner())
21356            .is_empty());
21357        drop(shared);
21358        drop(runtime);
21359        std::fs::remove_dir_all(root).unwrap();
21360    }
21361
21362    #[tokio::test]
21363    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21364        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21365        let address = SessionAddress {
21366            workspace_id: WorkspaceId::new("primary").unwrap(),
21367            session: SessionKey {
21368                instance_id: AgentInstanceId(203),
21369                generation: SessionGeneration(1),
21370            },
21371        };
21372        let (overlay, materialization_guard) = shared
21373            .prepare_session_materialization(
21374                &address,
21375                &agent("claude"),
21376                SessionMode::Pty,
21377                Some(&receipt),
21378                None,
21379                None,
21380                None,
21381            )
21382            .unwrap()
21383            .unwrap();
21384        let materialization_id = materialization_guard.id().unwrap().clone();
21385        let materialization_root = shared
21386            .materializations
21387            .lock()
21388            .unwrap_or_else(|poisoned| poisoned.into_inner())
21389            .get(&materialization_id)
21390            .unwrap()
21391            .root()
21392            .to_path_buf();
21393        let selection = shared
21394            .select_environment_profile(
21395                address.session.instance_id,
21396                &agent("claude"),
21397                SessionMode::Pty,
21398                Some(&receipt),
21399            )
21400            .unwrap()
21401            .unwrap();
21402        assert!(shared
21403            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21404            .unwrap()
21405            .is_none());
21406        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21407        let record_id = shared
21408            .bind_spawn_session_with_materialization(
21409                &address,
21410                agent("claude"),
21411                SessionMode::Pty,
21412                policy,
21413                SpawnRecordPolicy::ProviderIdentityOnly,
21414                Some(receipt.clone()),
21415                None,
21416                None,
21417                Some(materialization_id.clone()),
21418            )
21419            .unwrap()
21420            .unwrap();
21421        selection.retain();
21422        materialization_guard.retain();
21423        assert_eq!(
21424            shared
21425                .materializations
21426                .lock()
21427                .unwrap_or_else(|poisoned| poisoned.into_inner())
21428                .get(&materialization_id)
21429                .unwrap()
21430                .owner(),
21431            &MaterializationOwner::Record {
21432                record_id: record_id.clone(),
21433            },
21434        );
21435
21436        let removed = shared.remove_binding(&address).unwrap();
21437        shared
21438            .cleanup_session_owned_materialization(&address, &removed)
21439            .unwrap();
21440        assert!(materialization_root.is_dir());
21441        assert!(shared
21442            .resolve_record_materialization(
21443                &record_id,
21444                &agent("claude"),
21445                SessionMode::Pty,
21446                Some(&receipt),
21447                None,
21448                None,
21449            )
21450            .unwrap()
21451            .is_some());
21452        {
21453            let mut records = shared
21454                .session_records
21455                .lock()
21456                .unwrap_or_else(|poisoned| poisoned.into_inner());
21457            let record = records.records.get_mut(&record_id).unwrap();
21458            record.active_session = None;
21459            record.state = ManagedSessionState::Unavailable;
21460        }
21461        shared.forget_session_record(&record_id).await.unwrap();
21462        assert!(!materialization_root.exists());
21463        assert!(shared
21464            .materializations
21465            .lock()
21466            .unwrap_or_else(|poisoned| poisoned.into_inner())
21467            .is_empty());
21468        drop(shared);
21469        drop(runtime);
21470        std::fs::remove_dir_all(root).unwrap();
21471    }
21472
21473    #[test]
21474    fn record_materialization_revalidation_failure_persists_recovery_required() {
21475        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21476        let state_path = root.join("node-state-v6.json");
21477        shared.state_path = Some(state_path.clone());
21478        let (_address, _record_id, materialization_id, materialization_root) =
21479            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21480        std::fs::write(
21481            materialization_root.join(".gate4agent-materialization-owner"),
21482            b"tampered",
21483        )
21484        .unwrap();
21485
21486        let error = match shared.resolve_record_materialization(
21487            &_record_id,
21488            &agent("claude"),
21489            SessionMode::Pty,
21490            Some(&receipt),
21491            None,
21492            None,
21493        ) {
21494            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21495            Err(error) => error,
21496        };
21497        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21498        assert_eq!(
21499            shared
21500                .materializations
21501                .lock()
21502                .unwrap_or_else(|poisoned| poisoned.into_inner())
21503                .get(&materialization_id)
21504                .unwrap()
21505                .state(),
21506            MaterializationState::RecoveryRequired,
21507        );
21508        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21509        assert_eq!(loaded.materializations.len(), 1);
21510        assert_eq!(
21511            loaded.materializations[0].state(),
21512            MaterializationState::RecoveryRequired,
21513        );
21514        drop(shared);
21515        drop(runtime);
21516        std::fs::remove_dir_all(root).unwrap();
21517    }
21518
21519    #[test]
21520    fn transient_record_materialization_resolution_failure_remains_ready() {
21521        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21522        let (_address, record_id, materialization_id, _materialization_root) =
21523            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21524        deny.store(true, Ordering::Release);
21525
21526        let error = match shared.resolve_record_materialization(
21527            &record_id,
21528            &agent("claude"),
21529            SessionMode::Pty,
21530            Some(&receipt),
21531            None,
21532            None,
21533        ) {
21534            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21535            Err(error) => error,
21536        };
21537        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21538        assert_eq!(
21539            shared
21540                .materializations
21541                .lock()
21542                .unwrap_or_else(|poisoned| poisoned.into_inner())
21543                .get(&materialization_id)
21544                .unwrap()
21545                .state(),
21546            MaterializationState::Ready,
21547        );
21548        drop(shared);
21549        drop(runtime);
21550        std::fs::remove_dir_all(root).unwrap();
21551    }
21552
21553    #[test]
21554    fn startup_missing_record_materialization_is_not_resumable() {
21555        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21556        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21557        shared
21558            .insert_record(ManagedSessionRecord {
21559                record_id: record_id.clone(),
21560                display_name: "missing materialization".to_owned(),
21561                provider: agent("claude"),
21562                mode: SessionMode::Pty,
21563                state: ManagedSessionState::Dormant,
21564                workspace_id: WorkspaceId::new("primary").unwrap(),
21565                canonical_root: opaque_windows_path(
21566                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21567                ),
21568                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21569                    key: gate4agent_types::ProviderSessionKey::SessionId,
21570                    id: "provider-session-missing-materialization".to_owned(),
21571                    transcript_path: None,
21572                }),
21573                active_session: None,
21574                environment_profile: Some(receipt),
21575                bundle: None,
21576                context_id: None,
21577                context: None,
21578                exported_context: None,
21579                task_binding: None,
21580                created_at_unix_ms: 1,
21581                updated_at_unix_ms: 1,
21582                last_error: None,
21583            })
21584            .unwrap();
21585
21586        shared.reconcile_materializations();
21587        let record = shared.record(&record_id).unwrap();
21588        assert_eq!(record.state, ManagedSessionState::Unavailable);
21589        assert!(record.provider_session.is_none());
21590        assert_eq!(
21591            record.last_error.as_deref(),
21592            Some("environment-profile-unavailable"),
21593        );
21594        drop(shared);
21595        drop(runtime);
21596        std::fs::remove_dir_all(root).unwrap();
21597    }
21598
21599    #[tokio::test]
21600    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21601        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21602        let lease = install_managed_lease(&shared);
21603        let address = SessionAddress {
21604            workspace_id: WorkspaceId::new("primary").unwrap(),
21605            session: SessionKey {
21606                instance_id: AgentInstanceId(204),
21607                generation: SessionGeneration(1),
21608            },
21609        };
21610        let (overlay, materialization_guard) = shared
21611            .prepare_session_materialization(
21612                &address,
21613                &agent("claude"),
21614                SessionMode::Pty,
21615                Some(&receipt),
21616                None,
21617                None,
21618                Some(lease.lease_id.clone()),
21619            )
21620            .unwrap()
21621            .unwrap();
21622        let materialization_id = materialization_guard.id().unwrap().clone();
21623        let materialization_root = shared
21624            .materializations
21625            .lock()
21626            .unwrap_or_else(|poisoned| poisoned.into_inner())
21627            .get(&materialization_id)
21628            .unwrap()
21629            .root()
21630            .to_path_buf();
21631        let selection = shared
21632            .select_environment_profile(
21633                address.session.instance_id,
21634                &agent("claude"),
21635                SessionMode::Pty,
21636                Some(&receipt),
21637            )
21638            .unwrap()
21639            .unwrap();
21640        assert!(shared
21641            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21642            .unwrap()
21643            .is_none());
21644        shared.bind_session_with_materialization(
21645            &address,
21646            ProviderRuntimePolicy::raw_pty(),
21647            Some(receipt),
21648            None,
21649            None,
21650            Some(materialization_id),
21651        );
21652        selection.retain();
21653        materialization_guard.retain();
21654
21655        let blocked = shared
21656            .cleanup_managed_worktree(&lease.lease_id, false)
21657            .await
21658            .unwrap_err();
21659        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21660        assert!(materialization_root.is_dir());
21661        let removed = shared.remove_binding(&address).unwrap();
21662        shared
21663            .cleanup_session_owned_materialization(&address, &removed)
21664            .unwrap();
21665        assert!(!materialization_root.exists());
21666        drop(shared);
21667        drop(runtime);
21668        std::fs::remove_dir_all(root).unwrap();
21669    }
21670
21671    #[tokio::test]
21672    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21673        let git_root = temporary_workspace_root("raw-managed-inventory");
21674        let source = git_root.join("source");
21675        let allocation = git_root.join("allocation");
21676        std::fs::create_dir_all(&source).unwrap();
21677        std::fs::create_dir_all(&allocation).unwrap();
21678        run_test_git(&source, &["init"]);
21679        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21680        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21681        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21682        run_test_git(&source, &["add", "seed.txt"]);
21683        run_test_git(&source, &["commit", "-m", "seed"]);
21684        let profile = ManagedWorktreeProfile::new(
21685            WorktreeProfileId::new("default").unwrap(),
21686            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21687            &allocation,
21688            "gate4agent",
21689            "HEAD",
21690            ManagedWorktreeRetention::RemoveWhenReleased,
21691        )
21692        .unwrap();
21693        let workspace = WorkspaceConfig::new(
21694            WorkspaceId::new("primary").unwrap(),
21695            &source,
21696        )
21697        .unwrap()
21698        .with_worktree_service_mode(WorktreeServiceMode::Managed)
21699        .with_managed_worktree_profile(profile.clone())
21700        .unwrap();
21701        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21702            materialization_test_shared_with_workspace(false, workspace);
21703        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21704        let source_text = source.to_string_lossy().into_owned();
21705        let target_text = target.to_string_lossy().into_owned();
21706        let created = create_git_worktree(
21707            &source_text,
21708            &target_text,
21709            "gate4agent/mw-raw-inventory",
21710            Some("HEAD"),
21711        )
21712        .await
21713        .unwrap();
21714        let lease = ManagedWorktreeLeaseRecord {
21715            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21716            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21717            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21718            profile_id: profile.profile_id().clone(),
21719            profile_revision: profile.revision().clone(),
21720            target_root: created.path.clone(),
21721            branch: "gate4agent/mw-raw-inventory".to_owned(),
21722            base_commit: created.head.clone(),
21723            expected_head: Some(created.head.clone()),
21724            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21725            state: ManagedWorktreeLeaseState::Ready,
21726            session_holders: Vec::new(),
21727            record_holders: Vec::new(),
21728            cleanup_failure: None,
21729            created_at_unix_ms: 1,
21730            updated_at_unix_ms: 1,
21731        };
21732        shared
21733            .workspaces
21734            .write()
21735            .unwrap_or_else(|poisoned| poisoned.into_inner())
21736            .insert(lease.workspace_id.clone(), created.path.clone());
21737        *shared
21738            .managed_worktrees
21739            .lock()
21740            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21741            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21742        let address = SessionAddress {
21743            workspace_id: lease.workspace_id.clone(),
21744            session: SessionKey {
21745                instance_id: AgentInstanceId(205),
21746                generation: SessionGeneration(1),
21747            },
21748        };
21749        let (overlay, materialization_guard) = shared
21750            .prepare_session_materialization(
21751                &address,
21752                &agent("claude"),
21753                SessionMode::Pty,
21754                Some(&receipt),
21755                None,
21756                None,
21757                Some(lease.lease_id.clone()),
21758            )
21759            .unwrap()
21760            .unwrap();
21761        let materialization_id = materialization_guard.id().unwrap().clone();
21762        let materialization_root = shared
21763            .materializations
21764            .lock()
21765            .unwrap_or_else(|poisoned| poisoned.into_inner())
21766            .get(&materialization_id)
21767            .unwrap()
21768            .root()
21769            .to_path_buf();
21770        let selection = shared
21771            .select_environment_profile(
21772                address.session.instance_id,
21773                &agent("claude"),
21774                SessionMode::Pty,
21775                Some(&receipt),
21776            )
21777            .unwrap()
21778            .unwrap();
21779        assert!(shared
21780            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21781            .unwrap()
21782            .is_none());
21783        let record_id = shared
21784            .bind_spawn_session_with_materialization(
21785                &address,
21786                agent("claude"),
21787                SessionMode::Pty,
21788                ProviderRuntimePolicy::raw_pty(),
21789                SpawnRecordPolicy::Always,
21790                Some(receipt.clone()),
21791                None,
21792                None,
21793                Some(materialization_id.clone()),
21794            )
21795            .unwrap()
21796            .unwrap();
21797        selection.retain();
21798        materialization_guard.retain();
21799        let record = shared.record(&record_id).unwrap();
21800        assert_eq!(record.state, ManagedSessionState::Live);
21801        assert!(record.provider_session.is_none());
21802        assert!(record.environment_profile.is_none());
21803        assert!(record.bundle.is_none());
21804        assert!(record.context_id.is_none());
21805        assert!(record.context.is_none());
21806        assert_eq!(record.active_session.as_ref(), Some(&address));
21807        assert_eq!(
21808            shared
21809                .materializations
21810                .lock()
21811                .unwrap_or_else(|poisoned| poisoned.into_inner())
21812                .get(&materialization_id)
21813                .unwrap()
21814                .owner(),
21815            &MaterializationOwner::Session {
21816                incarnation_id: shared.incarnation_id,
21817                instance_id: address.session.instance_id,
21818                generation: address.session.generation,
21819            },
21820        );
21821        {
21822            let mut bindings = shared
21823                .session_bindings
21824                .lock()
21825                .unwrap_or_else(|poisoned| poisoned.into_inner());
21826            bindings
21827                .get_mut(&address.session.instance_id)
21828                .unwrap()
21829                .managed_worktree_lease_id = Some(lease.lease_id.clone());
21830        }
21831        let lease_snapshot = shared
21832            .managed_worktrees
21833            .lock()
21834            .unwrap_or_else(|poisoned| poisoned.into_inner())
21835            .bind_session(
21836                &lease.lease_id,
21837                ManagedWorktreeSessionHolder {
21838                    incarnation_id: shared.incarnation_id,
21839                    instance_id: address.session.instance_id,
21840                    generation: address.session.generation,
21841                },
21842                None,
21843                unix_time_ms(),
21844            )
21845            .unwrap();
21846        assert_eq!(lease_snapshot.active_session_count, 1);
21847        assert_eq!(lease_snapshot.managed_record_count, 0);
21848        shared.persist_state().unwrap();
21849        assert!(shared
21850            .managed_worktrees
21851            .lock()
21852            .unwrap_or_else(|poisoned| poisoned.into_inner())
21853            .get(&lease.lease_id)
21854            .unwrap()
21855            .record_holders
21856            .is_empty());
21857        shared
21858            .handle
21859            .dispatch(shared.prepare_command(ControlCommand::Register {
21860                instance_id: address.session.instance_id,
21861                agent_id: agent("claude"),
21862                transport: TransportKind::Pty,
21863            }))
21864            .unwrap();
21865        runtime.tick().await;
21866        shared.reconcile_managed_record(
21867            &record_id,
21868            &address,
21869            &ControlEvent {
21870                sequence: 1,
21871                command_id: None,
21872                instance_id: address.session.instance_id,
21873                generation: address.session.generation,
21874                event: ControlEventKind::Removed,
21875            },
21876            false,
21877            false,
21878        );
21879        let remove = shared.remove_session(&address);
21880        let drive_runtime = async {
21881            runtime.tick().await;
21882        };
21883        let (removed, ()) = tokio::join!(remove, drive_runtime);
21884        removed.unwrap();
21885
21886        assert!(!materialization_root.exists());
21887        assert!(!Path::new(&created.path).exists());
21888        assert_eq!(
21889            shared
21890                .managed_worktrees
21891                .lock()
21892                .unwrap_or_else(|poisoned| poisoned.into_inner())
21893                .get(&lease.lease_id)
21894                .unwrap()
21895                .state,
21896            ManagedWorktreeLeaseState::Removed,
21897        );
21898        let renamed = shared
21899            .rename_session_record(&record_id, "raw managed inventory".to_owned())
21900            .unwrap();
21901        assert_eq!(renamed.record_id, record_id);
21902        assert_eq!(renamed.display_name, "raw managed inventory");
21903        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21904        assert!(renamed.active_session.is_none());
21905
21906        drop(shared);
21907        drop(runtime);
21908        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21909        std::fs::remove_dir_all(git_root).unwrap();
21910    }
21911
21912    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21913        let root = std::env::current_dir().unwrap()
21914            .join("managed-test-target")
21915            .to_string_lossy()
21916            .into_owned();
21917        let lease = ManagedWorktreeLeaseRecord {
21918            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21919            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21920            workspace_id: WorkspaceId::new("managed-test").unwrap(),
21921            profile_id: WorktreeProfileId::new("default").unwrap(),
21922            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21923            target_root: root.clone(),
21924            branch: "gate4agent/mw-test".to_owned(),
21925            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21926            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21927            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21928            state: ManagedWorktreeLeaseState::Ready,
21929            session_holders: Vec::new(),
21930            record_holders: Vec::new(),
21931            cleanup_failure: None,
21932            created_at_unix_ms: 1,
21933            updated_at_unix_ms: 1,
21934        };
21935        shared.workspaces.write()
21936            .unwrap_or_else(|poisoned| poisoned.into_inner())
21937            .insert(lease.workspace_id.clone(), root);
21938        *shared.managed_worktrees.lock()
21939            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21940            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21941        lease
21942    }
21943
21944    #[cfg(feature = "fixture")]
21945    #[test]
21946    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21947        let mut shared = terminal_test_shared();
21948        shared.fixture_semantic_hook_policy = true;
21949        let lease = install_managed_lease(&shared);
21950        let address = SessionAddress {
21951            workspace_id: lease.workspace_id.clone(),
21952            session: SessionKey {
21953                instance_id: AgentInstanceId(206),
21954                generation: SessionGeneration(1),
21955            },
21956        };
21957        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21958        let effective_runtime_policy =
21959            shared.effective_spawn_runtime_policy(pre_admission_policy);
21960        assert_ne!(effective_runtime_policy, pre_admission_policy);
21961        assert!(effective_runtime_policy.provider_session_identity);
21962
21963        let record_id = shared
21964            .bind_spawn_session(
21965                &address,
21966                agent("claude"),
21967                SessionMode::Pty,
21968                effective_runtime_policy,
21969                SpawnRecordPolicy::Always,
21970                None,
21971            )
21972            .unwrap()
21973            .unwrap();
21974        assert_eq!(
21975            shared.record(&record_id).unwrap().state,
21976            ManagedSessionState::IdentityPending,
21977        );
21978        let (record_holder, raw_inventory_record_id) = {
21979            let mut bindings = shared
21980                .session_bindings
21981                .lock()
21982                .unwrap_or_else(|poisoned| poisoned.into_inner());
21983            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
21984            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
21985            managed_spawn_record_ownership(binding, effective_runtime_policy)
21986        };
21987        assert_eq!(record_holder.as_ref(), Some(&record_id));
21988        assert!(raw_inventory_record_id.is_none());
21989        let snapshot = shared
21990            .managed_worktrees
21991            .lock()
21992            .unwrap_or_else(|poisoned| poisoned.into_inner())
21993            .bind_session(
21994                &lease.lease_id,
21995                ManagedWorktreeSessionHolder {
21996                    incarnation_id: shared.incarnation_id,
21997                    instance_id: address.session.instance_id,
21998                    generation: address.session.generation,
21999                },
22000                record_holder,
22001                unix_time_ms(),
22002            )
22003            .unwrap();
22004        assert_eq!(snapshot.managed_record_count, 1);
22005        assert_eq!(
22006            shared
22007                .managed_worktrees
22008                .lock()
22009                .unwrap_or_else(|poisoned| poisoned.into_inner())
22010                .get(&lease.lease_id)
22011                .unwrap()
22012                .record_holders,
22013            vec![record_id.clone()],
22014        );
22015
22016        let identity = ProviderSessionIdentity {
22017            key: ProviderSessionKey::SessionId,
22018            id: "monitoring-fixture-managed-session".to_owned(),
22019            transcript_path: None,
22020        };
22021        shared.publish_control(ControlEvent {
22022            sequence: 1,
22023            command_id: None,
22024            instance_id: address.session.instance_id,
22025            generation: address.session.generation,
22026            event: ControlEventKind::ProviderEvent {
22027                sequence: 1,
22028                source: gate4agent_types::ProviderSource {
22029                    family: AdapterFamily::Hook,
22030                    binding: AdapterBinding::new(
22031                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
22032                        "fixture/v1",
22033                        gate4agent_types::AdapterVerification::SyntheticFixture,
22034                    )
22035                    .unwrap(),
22036                },
22037                source_sequence: 1,
22038                event: ProviderEvent::SessionIdentityObserved {
22039                    identity: identity.clone(),
22040                },
22041            },
22042        });
22043
22044        let live = shared.record(&record_id).unwrap();
22045        assert_eq!(live.state, ManagedSessionState::Live);
22046        assert_eq!(live.provider_session.as_ref(), Some(&identity));
22047        assert_eq!(live.active_session.as_ref(), Some(&address));
22048        assert_eq!(
22049            shared
22050                .managed_worktrees
22051                .lock()
22052                .unwrap_or_else(|poisoned| poisoned.into_inner())
22053                .get(&lease.lease_id)
22054                .unwrap()
22055                .record_holders,
22056            vec![record_id],
22057        );
22058    }
22059
22060    fn managed_v2_test_shared(
22061        allocation_root: &Path,
22062        state_path: Option<PathBuf>,
22063        profile_revision: &str,
22064    ) -> NodeShared {
22065        let profile = ManagedWorktreeProfile::new(
22066            WorktreeProfileId::new("default").unwrap(),
22067            WorktreeProfileRevision::new(profile_revision).unwrap(),
22068            allocation_root,
22069            "gate4agent",
22070            "HEAD",
22071            ManagedWorktreeRetention::RemoveWhenReleased,
22072        )
22073        .unwrap();
22074        let workspace = WorkspaceConfig::new(
22075            WorkspaceId::new("primary").unwrap(),
22076            std::env::current_dir().unwrap(),
22077        )
22078        .unwrap()
22079        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22080        .with_managed_worktree_profile(profile)
22081        .unwrap();
22082        let catalog = active_registry().unwrap();
22083        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22084        let mut shared = NodeShared::new(
22085            handle,
22086            "fixture-token".to_owned(),
22087            NodeId::new("node-terminal-test").unwrap(),
22088            vec![workspace],
22089            vec![agent("claude")],
22090        );
22091        shared.state_path = state_path;
22092        shared
22093    }
22094
22095    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22096        let mut spawn_spec = spawn_spec_fixture();
22097        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22098        ManagedWorktreeSpawnRequestV2 {
22099            spawn_spec,
22100            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22101            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22102        }
22103    }
22104
22105    #[test]
22106    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22107        let shared = terminal_test_shared();
22108        let mut lease = install_managed_lease(&shared);
22109        let session = SessionAddress {
22110            workspace_id: lease.workspace_id.clone(),
22111            session: SessionKey {
22112                instance_id: AgentInstanceId(73),
22113                generation: SessionGeneration(4),
22114            },
22115        };
22116        lease.state = ManagedWorktreeLeaseState::InUse;
22117        lease.session_holders.push(ManagedWorktreeSessionHolder {
22118            incarnation_id: shared.incarnation_id,
22119            instance_id: session.session.instance_id,
22120            generation: session.session.generation,
22121        });
22122        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22123        assert!(resolved.target.worktree_id.is_none());
22124
22125        let receipt = managed_spawn_receipt(
22126            &resolved,
22127            shared.incarnation_id,
22128            session.clone(),
22129            lease.snapshot(),
22130            None,
22131            None,
22132            None,
22133        );
22134        assert!(resolved.target.worktree_id.is_none());
22135        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22136        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22137        assert_eq!(receipt.spawn.session, session);
22138        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22139        let encoded = serde_json::to_vec(&receipt).unwrap();
22140        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22141        assert_eq!(decoded, receipt);
22142    }
22143
22144    #[tokio::test]
22145    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22146        let root = temporary_workspace_root("managed-v2-revision");
22147        let allocation = root.join("allocation");
22148        std::fs::create_dir_all(&allocation).unwrap();
22149        let shared = managed_v2_test_shared(
22150            &allocation,
22151            Some(root.join("node-state.json")),
22152            "v1",
22153        );
22154        let mut request = managed_v2_request("managed-v2-revision");
22155        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22156
22157        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22158
22159        assert_eq!(
22160            failure.code,
22161            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22162        );
22163        assert!(shared
22164            .managed_worktrees
22165            .lock()
22166            .unwrap_or_else(|poisoned| poisoned.into_inner())
22167            .records()
22168            .is_empty());
22169        assert!(shared
22170            .managed_spawn_replays
22171            .lock()
22172            .unwrap_or_else(|poisoned| poisoned.into_inner())
22173            .is_empty());
22174        std::fs::remove_dir_all(root).unwrap();
22175    }
22176
22177    #[test]
22178    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22179        let root = temporary_workspace_root("managed-v2-concurrent");
22180        let allocation = root.join("allocation");
22181        std::fs::create_dir_all(&allocation).unwrap();
22182        let shared = Arc::new(managed_v2_test_shared(
22183            &allocation,
22184            Some(root.join("node-state.json")),
22185            "v1",
22186        ));
22187        let request = managed_v2_request("managed-v2-concurrent");
22188        let barrier = Arc::new(std::sync::Barrier::new(3));
22189        let mut joins = Vec::new();
22190        for _ in 0..2 {
22191            let shared = Arc::clone(&shared);
22192            let request = request.clone();
22193            let barrier = Arc::clone(&barrier);
22194            joins.push(std::thread::spawn(move || {
22195                let digest = managed_spawn_request_digest_v2(&request).unwrap();
22196                barrier.wait();
22197                shared.acquire_durable_managed_spawn_v2(&request, digest)
22198            }));
22199        }
22200        barrier.wait();
22201        let results = joins
22202            .into_iter()
22203            .map(|join| join.join().unwrap())
22204            .collect::<Vec<_>>();
22205        assert_eq!(
22206            results
22207                .iter()
22208                .filter(|result| {
22209                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22210                })
22211                .count(),
22212            1,
22213        );
22214        assert_eq!(
22215            results
22216                .iter()
22217                .filter(|result| {
22218                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22219                })
22220                .count(),
22221            1,
22222        );
22223        assert_eq!(
22224            shared
22225                .managed_spawn_replays
22226                .lock()
22227                .unwrap_or_else(|poisoned| poisoned.into_inner())
22228                .len(),
22229            1,
22230        );
22231        assert!(shared
22232            .managed_worktrees
22233            .lock()
22234            .unwrap_or_else(|poisoned| poisoned.into_inner())
22235            .records()
22236            .is_empty());
22237        drop(shared);
22238        std::fs::remove_dir_all(root).unwrap();
22239    }
22240
22241    #[tokio::test]
22242    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22243        let root = temporary_workspace_root("managed-v2-linked-pending");
22244        let allocation = root.join("allocation");
22245        std::fs::create_dir_all(&allocation).unwrap();
22246        let state_path = root.join("node-state.json");
22247        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22248        let lease = install_managed_lease(&shared);
22249        let request = managed_v2_request("managed-v2-linked-pending");
22250        assert!(matches!(
22251            shared
22252                .acquire_durable_managed_spawn_v2(
22253                    &request,
22254                    managed_spawn_request_digest_v2(&request).unwrap(),
22255                )
22256                .unwrap(),
22257            DurableManagedSpawnReplayDecision::Reserved,
22258        ));
22259        {
22260            let _transaction = shared
22261                .state_transaction
22262                .lock()
22263                .unwrap_or_else(|poisoned| poisoned.into_inner());
22264            shared
22265                .link_durable_managed_spawn_v2_lease_locked(
22266                    &request.spawn_spec.idempotency_key,
22267                    &lease.lease_id,
22268                )
22269                .unwrap();
22270            shared.persist_state_locked().unwrap();
22271        }
22272        drop(shared);
22273
22274        let mut loaded = session_registry::load(
22275            Some(&state_path),
22276            &NodeId::new("node-terminal-test").unwrap(),
22277        )
22278        .unwrap();
22279        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22280        *reopened
22281            .managed_worktrees
22282            .lock()
22283            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22284            ManagedWorktreeRegistry::from_records(
22285                std::mem::take(&mut loaded.managed_worktrees),
22286                std::mem::take(&mut loaded.managed_worktree_tombstones),
22287            )
22288            .unwrap();
22289        *reopened
22290            .managed_spawn_replays
22291            .lock()
22292            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22293            .managed_spawn_replays
22294            .into_iter()
22295            .map(|record| (record.idempotency_key.clone(), record))
22296            .collect();
22297        let lease_count = reopened
22298            .managed_worktrees
22299            .lock()
22300            .unwrap_or_else(|poisoned| poisoned.into_inner())
22301            .records()
22302            .len();
22303
22304        let failure = reopened
22305            .spawn_managed_worktree_v2(request)
22306            .await
22307            .unwrap_err();
22308
22309        assert_eq!(
22310            failure.code,
22311            NodeFailureCode::ManagedWorktreeRecoveryRequired,
22312        );
22313        assert_eq!(
22314            reopened
22315                .managed_worktrees
22316                .lock()
22317                .unwrap_or_else(|poisoned| poisoned.into_inner())
22318                .records()
22319                .len(),
22320            lease_count,
22321        );
22322        std::fs::remove_dir_all(root).unwrap();
22323    }
22324
22325    #[tokio::test]
22326    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22327        let root = temporary_workspace_root("managed-v2-reopen");
22328        let allocation = root.join("allocation");
22329        std::fs::create_dir_all(&allocation).unwrap();
22330        let state_path = root.join("node-state.json");
22331        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22332        let lease = install_managed_lease(&shared);
22333        let request = managed_v2_request("managed-v2-reopen");
22334        let session = SessionAddress {
22335            workspace_id: lease.workspace_id.clone(),
22336            session: SessionKey {
22337                instance_id: AgentInstanceId(91),
22338                generation: SessionGeneration(1),
22339            },
22340        };
22341        let snapshot = {
22342            let mut registry = shared
22343                .managed_worktrees
22344                .lock()
22345                .unwrap_or_else(|poisoned| poisoned.into_inner());
22346            let current = registry.get_mut(&lease.lease_id).unwrap();
22347            current.state = ManagedWorktreeLeaseState::InUse;
22348            current.session_holders.push(ManagedWorktreeSessionHolder {
22349                incarnation_id: shared.incarnation_id,
22350                instance_id: session.session.instance_id,
22351                generation: session.session.generation,
22352            });
22353            current.snapshot()
22354        };
22355        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22356        let receipt = managed_spawn_receipt(
22357            &resolved,
22358            shared.incarnation_id,
22359            session,
22360            snapshot,
22361            None,
22362            None,
22363            None,
22364        );
22365        assert!(matches!(
22366            shared
22367                .acquire_durable_managed_spawn_v2(
22368                    &request,
22369                    managed_spawn_request_digest_v2(&request).unwrap(),
22370                )
22371                .unwrap(),
22372            DurableManagedSpawnReplayDecision::Reserved,
22373        ));
22374        {
22375            let _transaction = shared
22376                .state_transaction
22377                .lock()
22378                .unwrap_or_else(|poisoned| poisoned.into_inner());
22379            shared
22380                .link_durable_managed_spawn_v2_lease_locked(
22381                    &request.spawn_spec.idempotency_key,
22382                    &lease.lease_id,
22383                )
22384                .unwrap();
22385            shared.persist_state_locked().unwrap();
22386        }
22387        shared
22388            .commit_durable_managed_spawn_v2(&request, receipt.clone())
22389            .unwrap();
22390        let legacy_same_key = ManagedWorktreeSpawnRequest {
22391            spawn_spec: request.spawn_spec.clone(),
22392            worktree_profile_id: request.worktree_profile_id.clone(),
22393        };
22394        shared.remember_managed_spawn_attempt(
22395            Some(&request.spawn_spec.idempotency_key),
22396            legacy_same_key.clone(),
22397            Ok(receipt.clone()),
22398            Instant::now(),
22399        );
22400        assert!(shared
22401            .replay_managed_spawn(&legacy_same_key, Instant::now())
22402            .unwrap()
22403            .is_none());
22404        drop(shared);
22405
22406        let mut loaded = session_registry::load(
22407            Some(&state_path),
22408            &NodeId::new("node-terminal-test").unwrap(),
22409        )
22410        .unwrap();
22411        assert_eq!(loaded.managed_spawn_replays.len(), 1);
22412        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22413        *reopened
22414            .managed_worktrees
22415            .lock()
22416            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22417            ManagedWorktreeRegistry::from_records(
22418                std::mem::take(&mut loaded.managed_worktrees),
22419                std::mem::take(&mut loaded.managed_worktree_tombstones),
22420            )
22421            .unwrap();
22422        *reopened
22423            .managed_spawn_replays
22424            .lock()
22425            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22426            .managed_spawn_replays
22427            .into_iter()
22428            .map(|record| (record.idempotency_key.clone(), record))
22429            .collect();
22430        let lease_count_before = reopened
22431            .managed_worktrees
22432            .lock()
22433            .unwrap_or_else(|poisoned| poisoned.into_inner())
22434            .records()
22435            .len();
22436
22437        assert_eq!(
22438            reopened
22439                .spawn_managed_worktree_v2(request.clone())
22440                .await
22441                .unwrap(),
22442            receipt,
22443        );
22444        assert_eq!(
22445            reopened
22446                .managed_worktrees
22447                .lock()
22448                .unwrap_or_else(|poisoned| poisoned.into_inner())
22449                .records()
22450                .len(),
22451            lease_count_before,
22452        );
22453        let mut changed = request;
22454        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22455        let failure = reopened
22456            .spawn_managed_worktree_v2(changed)
22457            .await
22458            .unwrap_err();
22459        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22460        assert_eq!(
22461            reopened
22462                .managed_worktrees
22463                .lock()
22464                .unwrap_or_else(|poisoned| poisoned.into_inner())
22465                .records()
22466                .len(),
22467            lease_count_before,
22468        );
22469        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22470        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22471        let failure = reopened
22472            .spawn_managed_worktree_v2(new_key)
22473            .await
22474            .unwrap_err();
22475        assert_eq!(
22476            failure.code,
22477            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22478        );
22479        assert_eq!(
22480            reopened
22481                .managed_worktrees
22482                .lock()
22483                .unwrap_or_else(|poisoned| poisoned.into_inner())
22484                .records()
22485                .len(),
22486            lease_count_before,
22487        );
22488        assert_eq!(
22489            reopened
22490                .managed_spawn_replays
22491                .lock()
22492                .unwrap_or_else(|poisoned| poisoned.into_inner())
22493                .len(),
22494            1,
22495        );
22496        std::fs::remove_dir_all(root).unwrap();
22497    }
22498
22499    #[test]
22500    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22501        let shared = terminal_test_shared();
22502        let request = ManagedWorktreeSpawnRequest {
22503            spawn_spec: spawn_spec_fixture(),
22504            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22505        };
22506        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22507        shared.remember_managed_spawn(
22508            request.clone(),
22509            Err(expected.clone()),
22510            Instant::now(),
22511        );
22512
22513        assert_eq!(
22514            shared
22515                .replay_managed_spawn(&request, Instant::now())
22516                .unwrap()
22517                .unwrap(),
22518            Err(expected),
22519        );
22520        assert!(shared
22521            .managed_spawn_replays
22522            .lock()
22523            .unwrap_or_else(|poisoned| poisoned.into_inner())
22524            .is_empty());
22525    }
22526
22527    #[tokio::test]
22528    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22529        let shared = terminal_test_shared();
22530        let lease = install_managed_lease(&shared);
22531        let raw = shared.spawn_session(
22532            lease.workspace_id.clone(),
22533            agent("claude"),
22534            SessionMode::Pty,
22535            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22536            None,
22537        ).await.unwrap_err();
22538        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22539        let mut spec = spawn_spec_fixture();
22540        spec.target.workspace_id = lease.workspace_id.clone();
22541        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22542        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22543        let mut selected = spawn_spec_fixture();
22544        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22545        selected.target.worktree_id = Some(lease.workspace_id);
22546        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22547        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22548        assert!(shared.handle.snapshot().sessions.is_empty());
22549    }
22550
22551    #[test]
22552    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22553        let shared = terminal_test_shared();
22554        let lease = install_managed_lease(&shared);
22555        let snapshot = snapshot_for_wire(
22556            &shared, true, true, true, false, true, true, false, false, true,
22557        );
22558        assert!(snapshot.managed_worktrees.is_empty());
22559        assert!(snapshot.workspaces.iter().all(|workspace| {
22560            workspace.managed_worktree_profiles.is_none()
22561        }));
22562        let mut reply = ResponseEnvelope {
22563            request_id: 1,
22564            result: Ok(NodeResponse::Resync {
22565                event_sequence: 1,
22566                oldest_available_sequence: 1,
22567                snapshot: shared.snapshot(),
22568                events: vec![NodeEventEnvelope {
22569                    sequence: 1,
22570                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22571                }],
22572            }),
22573        };
22574        project_response_without_managed_worktrees(&mut reply);
22575        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22576            panic!("projection changed the response kind");
22577        };
22578        assert!(snapshot.managed_worktrees.is_empty());
22579        assert!(events.is_empty());
22580        let mut git = git_snapshot_for_parser();
22581        git.branch = Some(lease.branch.clone());
22582        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22583            lease_id: lease.lease_id.clone(),
22584            source_workspace_id: lease.source_workspace_id.clone(),
22585            branch: lease.branch.clone(),
22586            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22587            active_session_count: 1,
22588            managed_record_count: 0,
22589        });
22590        let mut inspection_reply = ResponseEnvelope {
22591            request_id: 2,
22592            result: Ok(NodeResponse::WorkspaceInspected {
22593                inspection: WorkspaceInspection {
22594                    workspace_id: lease.workspace_id.clone(),
22595                    entries: Vec::new(),
22596                    tree_truncated: false,
22597                    git,
22598                    truncation: None,
22599                },
22600            }),
22601        };
22602        project_response_without_managed_worktrees(&mut inspection_reply);
22603        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22604            panic!("projection changed the inspection response kind");
22605        };
22606        assert!(inspection.git.managed_worktree.is_none());
22607        let failure = managed_git_worktree_failure(GitWorktreeError {
22608            kind: GitWorktreeErrorKind::Failed,
22609            message: r"git failed at C:\private\managed-target".to_owned(),
22610        });
22611        assert!(!failure.message.contains("private"));
22612        assert!(!failure.message.contains("managed-target"));
22613    }
22614
22615    #[tokio::test]
22616    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22617        let root = temporary_workspace_root("managed-restart");
22618        let source = root.join("source");
22619        let allocation = root.join("allocation");
22620        std::fs::create_dir_all(&source).unwrap();
22621        std::fs::create_dir_all(&allocation).unwrap();
22622        run_test_git(&source, &["init"]);
22623        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22624        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22625        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22626        run_test_git(&source, &["add", "seed.txt"]);
22627        run_test_git(&source, &["commit", "-m", "seed"]);
22628        let profile = ManagedWorktreeProfile::new(
22629            WorktreeProfileId::new("default").unwrap(),
22630            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22631            &allocation,
22632            "gate4agent",
22633            "HEAD",
22634            ManagedWorktreeRetention::Retain,
22635        ).unwrap();
22636        let workspace = WorkspaceConfig::new(
22637            WorkspaceId::new("primary").unwrap(),
22638            &source,
22639        ).unwrap()
22640            .with_worktree_service_mode(WorktreeServiceMode::Managed)
22641            .with_managed_worktree_profile(profile.clone()).unwrap();
22642        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22643        let source_text = workspace.canonical_root().to_owned();
22644        let target_text = target.to_string_lossy().into_owned();
22645        let created = create_git_worktree(
22646            &source_text,
22647            &target_text,
22648            "gate4agent/mw-restart",
22649            Some("HEAD"),
22650        ).await.unwrap();
22651        let lease = ManagedWorktreeLeaseRecord {
22652            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22653            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22654            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22655            profile_id: profile.profile_id().clone(),
22656            profile_revision: profile.revision().clone(),
22657            target_root: created.path.clone(),
22658            branch: "gate4agent/mw-restart".to_owned(),
22659            base_commit: created.head.clone(),
22660            expected_head: None,
22661            retention: ManagedWorktreeRetention::Retain,
22662            state: ManagedWorktreeLeaseState::Allocating,
22663            session_holders: Vec::new(),
22664            record_holders: Vec::new(),
22665            cleanup_failure: None,
22666            created_at_unix_ms: 1,
22667            updated_at_unix_ms: 1,
22668        };
22669        let catalog = active_registry().unwrap();
22670        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22671        let shared = NodeShared::new_with_incarnation(
22672            handle,
22673            "fixture-token".to_owned(),
22674            NodeId::new("node-managed-restart").unwrap(),
22675            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22676            vec![workspace],
22677            vec![agent("claude")],
22678            ProviderRuntimeStatuses::default(),
22679            None,
22680            Vec::new(),
22681            Vec::new(),
22682            SpawnProfileRegistry::default(),
22683            None,
22684            None,
22685            None,
22686            None,
22687            Vec::new(),
22688            vec![lease.clone()],
22689            Vec::new(),
22690            Vec::new(),
22691            None,
22692            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22693        );
22694        shared.reconcile_managed_worktrees().await;
22695        let retained = shared.managed_worktrees.lock()
22696            .unwrap_or_else(|poisoned| poisoned.into_inner())
22697            .get(&lease.lease_id).unwrap().clone();
22698        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22699        assert!(shared.workspaces.read()
22700            .unwrap_or_else(|poisoned| poisoned.into_inner())
22701            .contains_key(&lease.workspace_id));
22702        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22703        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22704        assert!(!Path::new(&created.path).exists());
22705        std::fs::remove_dir_all(root).unwrap();
22706    }
22707
22708    fn run_test_git(root: &Path, arguments: &[&str]) {
22709        let output = std::process::Command::new("git")
22710            .arg("-C").arg(root).args(arguments).output().unwrap();
22711        assert!(
22712            output.status.success(),
22713            "git {:?} failed: {}",
22714            arguments,
22715            String::from_utf8_lossy(&output.stderr),
22716        );
22717    }
22718
22719    #[tokio::test]
22720    async fn spawn_spec_validation_fails_before_session_mutation() {
22721        let shared = terminal_test_shared();
22722        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22723            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22724        }));
22725        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22726            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22727        }));
22728        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22729            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22730        }));
22731
22732        let mut target_mismatch = spawn_spec_fixture();
22733        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22734        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22735        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22736
22737        let mut unknown_profile = spawn_spec_fixture();
22738        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22739        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22740        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22741        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22742
22743        let mut revision_mismatch = spawn_spec_fixture();
22744        revision_mismatch.idempotency_key =
22745            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22746        revision_mismatch.expected_profile_revision =
22747            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22748        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22749        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22750
22751        let mut unsupported_materializer = spawn_spec_fixture();
22752        unsupported_materializer.idempotency_key =
22753            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22754        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22755            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22756        };
22757        let failure = shared
22758            .spawn_from_spec(unsupported_materializer)
22759            .await
22760            .unwrap_err();
22761        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22762
22763        let failure = shared
22764            .spawn_session_with_deadline(
22765                WorkspaceId::new("primary").unwrap(),
22766                agent("claude"),
22767                SessionMode::Pty,
22768                gate4agent_types::TerminalSize {
22769                    rows: 24,
22770                    columns: 80,
22771                },
22772                None,
22773                None,
22774                None,
22775                None,
22776                None,
22777                None,
22778                SpawnRecordPolicy::ProviderIdentityOnly,
22779                Some(Instant::now()),
22780                &[],
22781                None,
22782                ApprovalLevel::default(),
22783                None,
22784                None,
22785            )
22786            .await
22787            .unwrap_err();
22788        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22789        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22790        assert!(shared.handle.snapshot().sessions.is_empty());
22791    }
22792
22793    #[test]
22794    fn worktree_selection_requires_its_separate_negotiated_capability() {
22795        let mut spec = spawn_spec_fixture();
22796        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22797        let request = NodeRequest::SpawnSpec { spec };
22798        let spawn_spec_capability =
22799            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22800        assert!(request_uses_unnegotiated_capability(
22801            &request,
22802            &[spawn_spec_capability.clone()],
22803        ));
22804        let worktree_capability =
22805            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22806        assert!(request_uses_unnegotiated_capability(
22807            &request,
22808            &[spawn_spec_capability.clone(), worktree_capability.clone()],
22809        ));
22810        let profile_revision_capability =
22811            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22812        assert!(request_uses_unnegotiated_capability(
22813            &request,
22814            &[
22815                spawn_spec_capability.clone(),
22816                worktree_capability.clone(),
22817                profile_revision_capability.clone(),
22818            ],
22819        ));
22820        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
22821        // `Inherit`, not `Clear` — the profile's own defaults might still
22822        // materialize a bundle or a context pack, so both capabilities stay
22823        // conservatively required until negotiated (see
22824        // `requires_session_bundle_materialization_capability` /
22825        // `requires_history_context_pack_capability`).
22826        let bundle_materialization_capability =
22827            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22828        assert!(request_uses_unnegotiated_capability(
22829            &request,
22830            &[
22831                spawn_spec_capability.clone(),
22832                worktree_capability.clone(),
22833                profile_revision_capability.clone(),
22834                bundle_materialization_capability.clone(),
22835            ],
22836        ));
22837        let history_context_pack_capability =
22838            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22839        assert!(!request_uses_unnegotiated_capability(
22840            &request,
22841            &[
22842                spawn_spec_capability,
22843                worktree_capability,
22844                profile_revision_capability,
22845                bundle_materialization_capability,
22846                history_context_pack_capability,
22847            ],
22848        ));
22849    }
22850
22851    #[tokio::test]
22852    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22853        let mut shared = terminal_test_shared();
22854        let spec = spawn_spec_fixture();
22855        let receipt = shared
22856            .resolve_spawn_spec(&spec)
22857            .unwrap()
22858            .receipt(shared.incarnation_id, terminal_address(1));
22859        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22860
22861        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22862        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22863        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22864
22865        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22866
22867        let mut revision_conflicting = spec.clone();
22868        revision_conflicting.expected_profile_revision =
22869            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22870        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22871        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22872
22873        let mut stale_cache_miss = spec.clone();
22874        stale_cache_miss.idempotency_key =
22875            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22876        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22877        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22878
22879        let mut conflicting = spec;
22880        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22881        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22882        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22883        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22884        assert!(shared.handle.snapshot().sessions.is_empty());
22885    }
22886
22887    #[tokio::test]
22888    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22889        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22890        let mut spec = spawn_spec_fixture();
22891        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22892        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22893        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22894
22895        let failure = shared
22896            .create_worktree(
22897                WorkspaceId::new("primary").unwrap(),
22898                WorkspaceId::new("new-worktree").unwrap(),
22899                "not-an-absolute-path".to_owned(),
22900                "invalid branch".to_owned(),
22901                None,
22902            )
22903            .await
22904            .unwrap_err();
22905        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22906
22907        let failure = shared
22908            .remove_worktree(
22909                WorkspaceId::new("primary").unwrap(),
22910                "not-an-absolute-path".to_owned(),
22911            )
22912            .await
22913            .unwrap_err();
22914        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22915        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22916        assert!(shared.handle.snapshot().sessions.is_empty());
22917    }
22918
22919    #[tokio::test]
22920    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22921        let root = temporary_workspace_root("manual-worktree-compensation");
22922        let source = root.join("source");
22923        let target = root.join("target");
22924        let invalid_state_parent = root.join("state-parent-is-a-file");
22925        let invalid_state_path = invalid_state_parent.join("state.json");
22926        std::fs::create_dir_all(&source).unwrap();
22927        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22928        run_test_git(&source, &["init"]);
22929        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22930        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22931        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22932        run_test_git(&source, &["add", "seed.txt"]);
22933        run_test_git(&source, &["commit", "-m", "seed"]);
22934
22935        let catalog = active_registry().unwrap();
22936        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22937        let workspace = WorkspaceConfig::new(
22938            WorkspaceId::new("primary").unwrap(),
22939            &source,
22940        ).unwrap();
22941        let mut shared = NodeShared::new(
22942            handle,
22943            "fixture-token".to_owned(),
22944            NodeId::new("node-worktree-compensation").unwrap(),
22945            vec![workspace],
22946            vec![agent("claude")],
22947        );
22948        shared.state_path = Some(invalid_state_path);
22949
22950        let error = shared.create_worktree(
22951            WorkspaceId::new("primary").unwrap(),
22952            WorkspaceId::new("compensated").unwrap(),
22953            target.to_string_lossy().into_owned(),
22954            "gate4agent/compensated".to_owned(),
22955            Some("HEAD".to_owned()),
22956        ).await.unwrap_err();
22957
22958        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22959        assert!(!target.exists());
22960        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22961            .iter()
22962            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22963        std::fs::remove_dir_all(root).unwrap();
22964    }
22965
22966    #[test]
22967    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22968        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22969            NativeGitWorktreeSnapshot {
22970                path: path.to_owned(),
22971                head: "0123456789abcdef".to_owned(),
22972                branch: Some("refs/heads/test".to_owned()),
22973                is_bare,
22974                is_main,
22975                locked: false,
22976                lock_reason: None,
22977                prunable: false,
22978                prunable_reason: None,
22979                workspace_id: None,
22980            }
22981        }
22982
22983        let source = r"C:\repo";
22984        let selected = r"C:\repo-worktree";
22985        let listed = vec![
22986            worktree(source, true, false),
22987            worktree(selected, false, false),
22988        ];
22989        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
22990
22991        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
22992        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
22993
22994        let bare = vec![
22995            worktree(source, true, false),
22996            worktree(selected, false, true),
22997        ];
22998        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
22999        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23000
23001        let foreign_failure =
23002            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23003        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23004    }
23005
23006    fn terminal_frame(sequence: u64) -> TerminalFrame {
23007        TerminalFrame {
23008            sequence,
23009            size: gate4agent_types::TerminalSize {
23010                rows: 24,
23011                columns: 80,
23012            },
23013            cursor_row: 2,
23014            cursor_column: 3,
23015            contents: format!("frame-{sequence}"),
23016            formatted: vec![1, 2, 3],
23017            scrollback_formatted: Vec::new(),
23018            alternate_screen: false,
23019            mouse_protocol_enabled: false,
23020            mouse_protocol_encoding: Default::default(),
23021            produced_at_unix_ms: 0,
23022            screen_state: gate4agent_types::PtyScreenState::default(),
23023            bracketed_paste: None,
23024        }
23025    }
23026
23027    #[test]
23028    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23029        let shared = terminal_test_shared();
23030        let mut receiver = shared.terminal_event_tx.subscribe();
23031        let original = terminal_address(1);
23032
23033        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23034        let first = receiver.try_recv().unwrap();
23035        assert_eq!(first.len(), 1);
23036        assert!(matches!(
23037            &first[0].event,
23038            NodeEvent::TerminalFrame { address, frame }
23039                if address == &original && frame.sequence == 7
23040        ));
23041
23042        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23043        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23044        assert!(matches!(
23045            receiver.try_recv(),
23046            Err(broadcast::error::TryRecvError::Empty)
23047        ));
23048
23049        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23050        let advanced = receiver.try_recv().unwrap();
23051        assert_eq!(advanced[0].sequence, 2);
23052
23053        let resumed = terminal_address(2);
23054        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23055        let reset = receiver.try_recv().unwrap();
23056        assert_eq!(reset[0].sequence, 3);
23057        assert!(matches!(
23058            &reset[0].event,
23059            NodeEvent::TerminalFrame { address, frame }
23060                if address == &resumed && frame.sequence == 1
23061        ));
23062        shared.clear_terminal_frame_watermark(&resumed);
23063        assert!(!shared
23064            .terminal_frame_watermarks
23065            .lock()
23066            .unwrap_or_else(|poisoned| poisoned.into_inner())
23067            .contains_key(&resumed.session.instance_id));
23068    }
23069
23070    #[test]
23071    fn terminal_frame_batches_replace_without_entering_node_event_history() {
23072        let shared = terminal_test_shared();
23073        let mut receiver = shared.terminal_event_tx.subscribe();
23074        let address = terminal_address(1);
23075        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23076
23077        for sequence in 1..=count {
23078            shared.publish_terminal_frame_candidates(vec![(
23079                address.clone(),
23080                terminal_frame(sequence),
23081            )]);
23082        }
23083
23084        let history = shared
23085            .history
23086            .lock()
23087            .unwrap_or_else(|poisoned| poisoned.into_inner());
23088        assert!(history.events.is_empty());
23089        assert_eq!(history.last_sequence, count);
23090        assert_eq!(history.replay_floor_sequence, 1);
23091        drop(history);
23092        let NodeResponse::Resync {
23093            event_sequence,
23094            oldest_available_sequence,
23095            events,
23096            ..
23097        } = shared.resync(0) else {
23098            unreachable!("resync helper returned another response")
23099        };
23100        assert_eq!(event_sequence, count);
23101        assert_eq!(oldest_available_sequence, 1);
23102        assert!(events.is_empty());
23103        assert!(matches!(
23104            resync_required_event(&shared).event,
23105            NodeEvent::ResyncRequired {
23106                oldest_available_sequence: 1,
23107            }
23108        ));
23109        assert!(matches!(
23110            receiver.try_recv(),
23111            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23112        ));
23113        let latest = receiver.try_recv().unwrap();
23114        assert_eq!(latest.len(), 1);
23115        assert_eq!(latest[0].sequence, count);
23116    }
23117
23118    #[test]
23119    fn real_durable_eviction_advances_authoritative_replay_floor() {
23120        let shared = terminal_test_shared();
23121        for _ in 0..=NODE_EVENT_HISTORY_MAX {
23122            shared.publish(NodeEvent::ControllerChanged { controller: None });
23123        }
23124
23125        let NodeResponse::Resync {
23126            event_sequence,
23127            oldest_available_sequence,
23128            events,
23129            ..
23130        } = shared.resync(0) else {
23131            unreachable!("resync helper returned another response")
23132        };
23133        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23134        assert_eq!(oldest_available_sequence, 2);
23135        assert_eq!(events.first().map(|event| event.sequence), Some(2));
23136        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23137        assert!(matches!(
23138            resync_required_event(&shared).event,
23139            NodeEvent::ResyncRequired {
23140                oldest_available_sequence: 2,
23141            }
23142        ));
23143    }
23144
23145    #[test]
23146    fn legacy_terminal_frame_connection_has_no_live_subscription() {
23147        let shared = terminal_test_shared();
23148        let without_capability = terminal_event_subscription(&shared, false);
23149        let with_capability = terminal_event_subscription(&shared, true);
23150        assert!(without_capability.is_none());
23151        assert!(with_capability.is_some());
23152        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23153            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23154        }));
23155    }
23156
23157    #[test]
23158    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23159        let mut pending = Vec::new();
23160        let mut event_sequence = 0_u64;
23161        for frame_sequence in 1..=4 {
23162            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23163                event_sequence += 1;
23164                let mut address = terminal_address(1);
23165                address.session.instance_id = AgentInstanceId(instance);
23166                queue_connection_event(
23167                    &mut pending,
23168                    NodeEventEnvelope {
23169                        sequence: event_sequence,
23170                        event: NodeEvent::TerminalFrame {
23171                            address,
23172                            frame: terminal_frame(frame_sequence),
23173                        },
23174                    },
23175                    0,
23176                );
23177            }
23178        }
23179        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23180        pending.sort_unstable_by_key(|event| event.sequence);
23181
23182        let mut uninterrupted = pending.clone();
23183        let first_burst = connection_event_burst_len(uninterrupted.len());
23184        uninterrupted.drain(..first_burst);
23185        let second_burst = connection_event_burst_len(uninterrupted.len());
23186        uninterrupted.drain(..second_burst);
23187        assert!(uninterrupted.is_empty());
23188
23189        let (frame_tx, mut frame_rx) =
23190            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23191        frame_tx
23192            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23193                request_id: 77,
23194                request: NodeRequest::Snapshot,
23195            })))
23196            .unwrap();
23197        let burst_len = connection_event_burst_len(pending.len());
23198        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23199        pending.drain(..burst_len);
23200        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23201        assert!(matches!(
23202            frame_rx.try_recv(),
23203            Ok(Ok(ClientFrame::Request(RequestEnvelope {
23204                request_id: 77,
23205                request: NodeRequest::Snapshot,
23206            })))
23207        ));
23208    }
23209
23210    #[test]
23211    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23212        let mut pending = Vec::new();
23213        for sequence in [7_u64, 8] {
23214            let mut address = terminal_address(1);
23215            address.session.instance_id = AgentInstanceId(sequence);
23216            queue_connection_event(
23217                &mut pending,
23218                NodeEventEnvelope {
23219                    sequence,
23220                    event: NodeEvent::TerminalFrame {
23221                        address,
23222                        frame: terminal_frame(sequence),
23223                    },
23224                },
23225                0,
23226            );
23227        }
23228        let mut discard_through = 0;
23229        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23230        assert!(pending.is_empty());
23231
23232        let replayed = NodeEventEnvelope {
23233            sequence: 8,
23234            event: NodeEvent::TerminalFrame {
23235                address: terminal_address(1),
23236                frame: terminal_frame(8),
23237            },
23238        };
23239        let advanced = NodeEventEnvelope {
23240            sequence: 9,
23241            event: NodeEvent::TerminalFrame {
23242                address: terminal_address(2),
23243                frame: terminal_frame(1),
23244            },
23245        };
23246        queue_connection_event_batch(
23247            &mut pending,
23248            &[replayed, advanced.clone()],
23249            discard_through,
23250        );
23251        assert_eq!(pending, vec![advanced]);
23252    }
23253
23254    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23255        ManagedSessionRecord {
23256            record_id: SessionRecordId::new(record_id).unwrap(),
23257            display_name: format!("{provider} record"),
23258            provider: agent(provider),
23259            mode: SessionMode::Pty,
23260            state: ManagedSessionState::Unavailable,
23261            workspace_id: WorkspaceId::new("primary").unwrap(),
23262            canonical_root: opaque_windows_path(
23263                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23264            ),
23265            provider_session: None,
23266            active_session: None,
23267            environment_profile: None,
23268            bundle: None,
23269            context_id: None,
23270            context: None,
23271            exported_context: None,
23272            task_binding: None,
23273            created_at_unix_ms: 1,
23274            updated_at_unix_ms: 1,
23275            last_error: None,
23276        }
23277    }
23278
23279    fn retention_test_record(
23280        workspace_id: &str,
23281        record_id: &str,
23282        state: ManagedSessionState,
23283        updated_at_unix_ms: u64,
23284    ) -> ManagedSessionRecord {
23285        ManagedSessionRecord {
23286            record_id: SessionRecordId::new(record_id).unwrap(),
23287            display_name: format!("{record_id} display"),
23288            provider: agent("claude"),
23289            mode: SessionMode::Pty,
23290            state,
23291            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23292            canonical_root: opaque_windows_path(
23293                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23294            ),
23295            provider_session: None,
23296            active_session: None,
23297            environment_profile: None,
23298            bundle: None,
23299            context_id: None,
23300            context: None,
23301            exported_context: None,
23302            task_binding: None,
23303            created_at_unix_ms: updated_at_unix_ms,
23304            updated_at_unix_ms,
23305            last_error: None,
23306        }
23307    }
23308
23309    /// Two workspaces of three `Unavailable` records each, staggered by
23310    /// age, plus one never-touchable decoy per non-`Unavailable` state in
23311    /// `workspace-a` (all as old as that workspace's oldest real record --
23312    /// if the sweep's state gate ever leaked, these would be the first
23313    /// thing it wrongly retired). Ages, oldest to newest:
23314    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
23315    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
23316    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23317        vec![
23318            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23319            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23320            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23321            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23322            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23323            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23324            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23325            retention_test_record(
23326                "workspace-a",
23327                "decoy-pending",
23328                ManagedSessionState::IdentityPending,
23329                now - 500_000,
23330            ),
23331            retention_test_record(
23332                "workspace-a",
23333                "decoy-dormant",
23334                ManagedSessionState::Dormant,
23335                now - 500_000,
23336            ),
23337        ]
23338    }
23339
23340    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23341        let root = temporary_workspace_root("session-record-retention");
23342        std::fs::create_dir_all(&root).unwrap();
23343        let catalog = active_registry().unwrap();
23344        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23345        let workspace_a =
23346            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23347        let workspace_b =
23348            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23349        let mut shared = NodeShared::new(
23350            handle,
23351            "fixture-token".to_owned(),
23352            NodeId::new("node-retention-test").unwrap(),
23353            vec![workspace_a, workspace_b],
23354            vec![agent("claude")],
23355        );
23356        shared.state_path = Some(root.join("node-state-v10.json"));
23357        let now = unix_time_ms();
23358        for record in retention_fixture_records(now) {
23359            shared.insert_record(record).unwrap();
23360        }
23361        shared
23362            .persist_state_locked()
23363            .expect("fixture-only, empty-history persist against a fresh temp state path");
23364        (shared, runtime, root, now)
23365    }
23366
23367    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23368        shared
23369            .history
23370            .lock()
23371            .unwrap_or_else(|poisoned| poisoned.into_inner())
23372            .events
23373            .iter()
23374            .filter_map(|envelope| match &envelope.event {
23375                NodeEvent::SessionRecordRemoved { record_id } => {
23376                    Some(record_id.as_str().to_owned())
23377                }
23378                _ => None,
23379            })
23380            .collect()
23381    }
23382
23383    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23384        shared
23385            .session_records
23386            .lock()
23387            .unwrap_or_else(|poisoned| poisoned.into_inner())
23388            .records
23389            .keys()
23390            .map(|record_id| record_id.as_str().to_owned())
23391            .collect()
23392    }
23393
23394    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23395        values.iter().map(|value| (*value).to_owned()).collect()
23396    }
23397
23398    #[tokio::test]
23399    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23400        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23401        shared.session_record_retention = SessionRecordRetentionConfig {
23402            age_ms: 0,
23403            keep_per_workspace: 1,
23404        };
23405        shared.retire_unavailable_session_records().await;
23406
23407        assert_eq!(
23408            retention_remaining_record_ids(&shared),
23409            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23410        );
23411        assert_eq!(
23412            retention_removed_record_ids(&shared),
23413            owned(&["a1", "a2", "b1", "b2"]),
23414        );
23415        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23416
23417        drop(shared);
23418        drop(runtime);
23419        std::fs::remove_dir_all(root).unwrap();
23420    }
23421
23422    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
23423    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
23424    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
23425    /// 200s old against a 250s threshold) even though it fails the keep-N
23426    /// test too, proving retirement needs BOTH, not either.
23427    #[tokio::test]
23428    async fn session_record_retention_both_set_retires_only_the_intersection() {
23429        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23430        shared.session_record_retention = SessionRecordRetentionConfig {
23431            age_ms: 250_000,
23432            keep_per_workspace: 1,
23433        };
23434        shared.retire_unavailable_session_records().await;
23435
23436        assert_eq!(
23437            retention_remaining_record_ids(&shared),
23438            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23439        );
23440        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23441        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23442
23443        drop(shared);
23444        drop(runtime);
23445        std::fs::remove_dir_all(root).unwrap();
23446    }
23447
23448    #[tokio::test]
23449    async fn session_record_retention_both_disabled_is_a_no_op() {
23450        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23451        shared.session_record_retention = SessionRecordRetentionConfig::default();
23452        shared.retire_unavailable_session_records().await;
23453
23454        assert_eq!(
23455            retention_remaining_record_ids(&shared),
23456            owned(&[
23457                "a1", "a2", "a3", "b1", "b2", "b3",
23458                "decoy-live", "decoy-pending", "decoy-dormant",
23459            ]),
23460        );
23461        assert!(retention_removed_record_ids(&shared).is_empty());
23462        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23463
23464        drop(shared);
23465        drop(runtime);
23466        std::fs::remove_dir_all(root).unwrap();
23467    }
23468
23469    #[test]
23470    fn session_record_context_export_selects_one_exact_history_identity() {
23471        let candidates = vec![
23472            HistoryCandidateSummary {
23473                id: "candidate-other".to_owned(),
23474                session_id_hint: "provider-other".to_owned(),
23475                modified_at_unix_ms: Some(1),
23476            },
23477            HistoryCandidateSummary {
23478                id: "candidate-exact".to_owned(),
23479                session_id_hint: "provider-exact".to_owned(),
23480                modified_at_unix_ms: Some(2),
23481            },
23482        ];
23483        assert_eq!(
23484            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23485            "candidate-exact",
23486        );
23487    }
23488
23489    #[test]
23490    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23491        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23492            id: id.to_owned(),
23493            session_id_hint: hint.to_owned(),
23494            modified_at_unix_ms: None,
23495        };
23496        let missing = select_history_candidate(
23497            &[candidate("candidate-other", "provider-other")],
23498            Some("provider-exact"),
23499        )
23500        .unwrap_err();
23501        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23502        let ambiguous = select_history_candidate(
23503            &[
23504                candidate("candidate-a", "provider-exact"),
23505                candidate("candidate-b", "provider-exact"),
23506            ],
23507            Some("provider-exact"),
23508        )
23509        .unwrap_err();
23510        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23511    }
23512
23513    /// The PTY-sidecar-observation shape (no fleet provider today, but
23514    /// capability-keyed rather than vendor-locked) never carries a verified
23515    /// `provider_session`; its record-bound export instead falls back to
23516    /// "exactly one discovered candidate" (see
23517    /// `export_context_pack_for_session_record_inner`). This is the same
23518    /// ambiguity refusal as the identity-filtered path, just without an
23519    /// identity to filter by.
23520    #[test]
23521    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23522        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23523            id: id.to_owned(),
23524            session_id_hint: hint.to_owned(),
23525            modified_at_unix_ms: None,
23526        };
23527        assert_eq!(
23528            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23529                .unwrap(),
23530            "candidate-sole",
23531        );
23532        let missing = select_history_candidate(&[], None).unwrap_err();
23533        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23534        let ambiguous = select_history_candidate(
23535            &[
23536                candidate("candidate-a", "sidecar-session-a"),
23537                candidate("candidate-b", "sidecar-session-b"),
23538            ],
23539            None,
23540        )
23541        .unwrap_err();
23542        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23543    }
23544
23545    #[test]
23546    fn session_record_context_export_revalidation_rejects_record_change() {
23547        let session = terminal_address(1);
23548        let identity = ProviderSessionIdentity {
23549            key: ProviderSessionKey::SessionId,
23550            id: "provider-exact".to_owned(),
23551            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23552        };
23553        let mut expected = record("claude", "record-context-export");
23554        expected.state = ManagedSessionState::Live;
23555        expected.provider_session = Some(identity.clone());
23556        expected.active_session = Some(session.clone());
23557        let binding = SessionBinding {
23558            workspace_id: session.workspace_id.clone(),
23559            generation: session.session.generation,
23560            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23561            pending_resume: None,
23562            record_id: Some(expected.record_id.clone()),
23563            managed_worktree_lease_id: None,
23564            environment_profile: None,
23565            bundle: None,
23566            context: None,
23567            materialization_id: None,
23568        };
23569        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23570        assert!(session_record_context_export_binding_is_exact(
23571            &expected,
23572            &expected,
23573            Some(&identity),
23574            &session,
23575            &binding,
23576            &expected.provider,
23577            &current_root,
23578            false,
23579        ));
23580        let mut changed = expected.clone();
23581        changed.updated_at_unix_ms += 1;
23582        assert!(!session_record_context_export_binding_is_exact(
23583            &expected,
23584            &changed,
23585            Some(&identity),
23586            &session,
23587            &binding,
23588            &expected.provider,
23589            &current_root,
23590            false,
23591        ));
23592        // A same-instant timestamp bump alone (state and active_session
23593        // otherwise unchanged) is not the clean-detachment transition
23594        // either, even with the reactive flag on: only the exact Live ->
23595        // Dormant + active_session cleared shape qualifies.
23596        assert!(!session_record_context_export_binding_is_exact(
23597            &expected,
23598            &changed,
23599            Some(&identity),
23600            &session,
23601            &binding,
23602            &expected.provider,
23603            &current_root,
23604            true,
23605        ));
23606        let mut detached = expected.clone();
23607        detached.state = ManagedSessionState::Dormant;
23608        detached.active_session = None;
23609        detached.updated_at_unix_ms += 1;
23610        assert!(!session_record_context_export_binding_is_exact(
23611            &expected,
23612            &detached,
23613            Some(&identity),
23614            &session,
23615            &binding,
23616            &expected.provider,
23617            &current_root,
23618            false,
23619        ));
23620        assert!(session_record_context_export_binding_is_exact(
23621            &expected,
23622            &detached,
23623            Some(&identity),
23624            &session,
23625            &binding,
23626            &expected.provider,
23627            &current_root,
23628            true,
23629        ));
23630    }
23631
23632    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
23633    /// for a record whose provider never carries a verified identity (the
23634    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
23635    /// declares one today): `identity` is `None` throughout, and the
23636    /// exactness check falls back to comparing `provider_session` itself,
23637    /// which must still be `None` on both sides.
23638    #[test]
23639    fn session_record_context_export_binding_is_exact_without_identity() {
23640        let session = terminal_address(1);
23641        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23642        expected.state = ManagedSessionState::Live;
23643        expected.active_session = Some(session.clone());
23644        let binding = SessionBinding {
23645            workspace_id: session.workspace_id.clone(),
23646            generation: session.session.generation,
23647            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23648            pending_resume: None,
23649            record_id: Some(expected.record_id.clone()),
23650            managed_worktree_lease_id: None,
23651            environment_profile: None,
23652            bundle: None,
23653            context: None,
23654            materialization_id: None,
23655        };
23656        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23657        assert!(session_record_context_export_binding_is_exact(
23658            &expected,
23659            &expected,
23660            None,
23661            &session,
23662            &binding,
23663            &expected.provider,
23664            &current_root,
23665            false,
23666        ));
23667        // A record that gained an identity between the initial lookup and
23668        // this recheck no longer matches the `None` this shape expects to
23669        // keep seeing -- treated the same as any other mid-export drift.
23670        let mut drifted = expected.clone();
23671        drifted.provider_session = Some(ProviderSessionIdentity {
23672            key: ProviderSessionKey::SessionId,
23673            id: "unexpected-identity".to_owned(),
23674            transcript_path: None,
23675        });
23676        assert!(!session_record_context_export_binding_is_exact(
23677            &expected,
23678            &drifted,
23679            None,
23680            &session,
23681            &binding,
23682            &expected.provider,
23683            &current_root,
23684            false,
23685        ));
23686    }
23687
23688    #[test]
23689    fn session_record_context_export_preflight_requires_live_usable_source() {
23690        let mut source = record("claude", "record-context-export-preflight");
23691        source.state = ManagedSessionState::Live;
23692        assert!(session_record_context_export_source_is_usable(
23693            &source,
23694            &SessionStatus::Running,
23695            false,
23696        ));
23697        assert!(session_record_context_export_source_is_usable(
23698            &source,
23699            &SessionStatus::Exited { exit_code: Some(0) },
23700            false,
23701        ));
23702
23703        source.state = ManagedSessionState::Dormant;
23704        assert!(!session_record_context_export_source_is_usable(
23705            &source,
23706            &SessionStatus::Running,
23707            false,
23708        ));
23709        // The reactive auto-export-at-exit path (allow_clean_detachment) is
23710        // the only caller that may treat a Dormant, cleanly-exited record as
23711        // still usable — the on-demand path above never does.
23712        assert!(session_record_context_export_source_is_usable(
23713            &source,
23714            &SessionStatus::Exited { exit_code: Some(0) },
23715            true,
23716        ));
23717        assert!(!session_record_context_export_source_is_usable(
23718            &source,
23719            &SessionStatus::Exited { exit_code: Some(0) },
23720            false,
23721        ));
23722        source.state = ManagedSessionState::Live;
23723        assert!(!session_record_context_export_source_is_usable(
23724            &source,
23725            &SessionStatus::Starting,
23726            false,
23727        ));
23728    }
23729
23730    #[test]
23731    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23732        let catalog = active_registry().unwrap();
23733        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23734        let workspace = WorkspaceConfig::new(
23735            WorkspaceId::new("primary").unwrap(),
23736            std::env::current_dir().unwrap(),
23737        )
23738        .unwrap();
23739        let shared = NodeShared::new(
23740            handle,
23741            "fixture-token".to_owned(),
23742            NodeId::new("node-context-export-conflict").unwrap(),
23743            vec![workspace],
23744            vec![agent("claude")],
23745        );
23746        let history = HistorySessionRecord {
23747            session_id: "provider-exact".to_owned(),
23748            title: None,
23749            cwd: None,
23750            model: None,
23751            message_count: 1,
23752            completed_turn_count: None,
23753            total_tokens: 0,
23754            messages: vec![gate4agent_types::HistoryMessageRecord {
23755                role: gate4agent_types::HistoryMessageRole::User,
23756                text: "bounded context".to_owned(),
23757            }],
23758        };
23759        let pack = NodeContextPack::export(
23760            ContextPackLineageReceipt {
23761                source_node_id: shared.node_id.clone(),
23762                source_session: terminal_address(1),
23763                source_provider: agent("claude"),
23764            },
23765            &history,
23766        )
23767        .unwrap();
23768        let context_id = pack.receipt().id.clone();
23769        let session = terminal_address(1);
23770        let identity = ProviderSessionIdentity {
23771            key: ProviderSessionKey::SessionId,
23772            id: history.session_id.clone(),
23773            transcript_path: None,
23774        };
23775        let mut expected = record("claude", "record-context-export-final-conflict");
23776        expected.state = ManagedSessionState::Live;
23777        expected.provider_session = Some(identity.clone());
23778        expected.active_session = Some(session.clone());
23779        let mut changed = expected.clone();
23780        changed.updated_at_unix_ms += 1;
23781        shared.insert_record(changed).unwrap();
23782
23783        let error = shared
23784            .commit_context_pack_for_session_record(
23785                &expected,
23786                Some(&identity),
23787                &session,
23788                "candidate-exact",
23789                &history,
23790                pack,
23791                false,
23792            )
23793            .unwrap_err();
23794
23795        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23796        assert!(shared
23797            .context_catalog
23798            .read()
23799            .unwrap_or_else(|poisoned| poisoned.into_inner())
23800            .get(&context_id)
23801            .is_none());
23802    }
23803
23804    #[tokio::test]
23805    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23806        let root = temporary_workspace_root("session-task-binding");
23807        std::fs::create_dir_all(&root).unwrap();
23808        let state_path = root.join("node-state.json");
23809        let catalog = active_registry().unwrap();
23810        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23811        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23812        let node_id = NodeId::new("node-1").unwrap();
23813        let mut shared = NodeShared::new(
23814            handle,
23815            "fixture-token".to_owned(),
23816            node_id.clone(),
23817            vec![workspace],
23818            vec![agent("claude")],
23819        );
23820        shared.state_path = Some(state_path.clone());
23821        let record = record("claude", "sr-task");
23822        let record_id = record.record_id.clone();
23823        shared.insert_record(record).unwrap();
23824        shared.persist_state().unwrap();
23825        shared
23826            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23827            .unwrap();
23828
23829        let minted = process_request_inner(
23830            &shared,
23831            77,
23832            ClientRole::Operator,
23833            NodeRequest::SetSessionTask {
23834                record_id: record_id.clone(),
23835                expected_revision: 0,
23836                target: SessionTaskTargetV1::New,
23837            },
23838        )
23839        .await
23840        .unwrap();
23841        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23842            panic!("session task mutation returned another response");
23843        };
23844        let minted_binding = minted.task_binding.clone().unwrap();
23845        let minted_task_id = minted_binding.task_id.clone().unwrap();
23846        assert_eq!(minted_binding.revision, 1);
23847        assert!(minted_task_id.as_str().starts_with("task-"));
23848        assert_eq!(minted_task_id.as_str().len(), 29);
23849
23850        let event_count = shared.current_sequence();
23851        let repeated = process_request_inner(
23852            &shared,
23853            77,
23854            ClientRole::Operator,
23855            NodeRequest::SetSessionTask {
23856                record_id: record_id.clone(),
23857                expected_revision: 1,
23858                target: SessionTaskTargetV1::Existing {
23859                    task_id: minted_task_id.clone(),
23860                },
23861            },
23862        )
23863        .await
23864        .unwrap();
23865        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23866        assert_eq!(shared.current_sequence(), event_count);
23867
23868        let stale = process_request_inner(
23869            &shared,
23870            77,
23871            ClientRole::Operator,
23872            NodeRequest::SetSessionTask {
23873                record_id: record_id.clone(),
23874                expected_revision: 0,
23875                target: SessionTaskTargetV1::Existing {
23876                    task_id: minted_task_id,
23877                },
23878            },
23879        )
23880        .await
23881        .unwrap_err();
23882        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23883
23884        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23885        let assigned = process_request_inner(
23886            &shared,
23887            77,
23888            ClientRole::Operator,
23889            NodeRequest::SetSessionTask {
23890                record_id: record_id.clone(),
23891                expected_revision: 1,
23892                target: SessionTaskTargetV1::Existing {
23893                    task_id: assigned_id.clone(),
23894                },
23895            },
23896        )
23897        .await
23898        .unwrap();
23899        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23900            panic!("session task assignment returned another response");
23901        };
23902        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23903        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23904
23905        let cleared = process_request_inner(
23906            &shared,
23907            77,
23908            ClientRole::Operator,
23909            NodeRequest::SetSessionTask {
23910                record_id: record_id.clone(),
23911                expected_revision: 2,
23912                target: SessionTaskTargetV1::Clear,
23913            },
23914        )
23915        .await
23916        .unwrap();
23917        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23918            panic!("session task clear returned another response");
23919        };
23920        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23921        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23922        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23923        assert_eq!(
23924            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23925            cleared.updated_at_unix_ms,
23926        );
23927
23928        let persisted = std::fs::read(&state_path).unwrap();
23929        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23930        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23931        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23932        std::fs::remove_dir_all(root).unwrap();
23933    }
23934
23935    #[test]
23936    fn session_task_capability_strips_snapshots_events_and_replies() {
23937        let mut bound = record("claude", "sr-task-projection");
23938        bound.updated_at_unix_ms = 2;
23939        bound.task_binding = Some(SessionTaskBindingV1 {
23940            revision: 1,
23941            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23942            changed_at_unix_ms: 2,
23943        });
23944        let request = NodeRequest::SetSessionTask {
23945            record_id: bound.record_id.clone(),
23946            expected_revision: 1,
23947            target: SessionTaskTargetV1::Clear,
23948        };
23949        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23950            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23951        }));
23952        assert!(!request_uses_unnegotiated_capability(
23953            &request,
23954            &baseline_capabilities().unwrap(),
23955        ));
23956        assert!(request_uses_unnegotiated_capability(&request, &[]));
23957
23958        let mut snapshot = terminal_test_shared().snapshot();
23959        snapshot.session_records.push(bound.clone());
23960        let mut reply = ResponseEnvelope {
23961            request_id: 1,
23962            result: Ok(NodeResponse::Resync {
23963                event_sequence: 1,
23964                oldest_available_sequence: 1,
23965                snapshot,
23966                events: vec![NodeEventEnvelope {
23967                    sequence: 1,
23968                    event: NodeEvent::SessionRecordUpserted {
23969                        record: bound.clone(),
23970                    },
23971                }],
23972            }),
23973        };
23974        project_response_without_session_task_binding(&mut reply);
23975        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23976            panic!("task projection changed the response kind");
23977        };
23978        assert!(snapshot.session_records[0].task_binding.is_none());
23979        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
23980            panic!("task projection changed the event kind");
23981        };
23982        assert!(record.task_binding.is_none());
23983
23984        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
23985            sequence: 2,
23986            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
23987        });
23988        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
23989            panic!("task event projection changed the event kind");
23990        };
23991        assert!(record.task_binding.is_none());
23992
23993        let mut direct = ResponseEnvelope {
23994            request_id: 2,
23995            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
23996        };
23997        project_response_without_session_task_binding(&mut direct);
23998        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
23999            panic!("task reply projection changed the response kind");
24000        };
24001        assert!(record.task_binding.is_none());
24002    }
24003
24004    #[test]
24005    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24006        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24007        let workspace = WorkspaceConfig::new(
24008            WorkspaceId::new("primary").unwrap(),
24009            std::env::current_dir().unwrap(),
24010        )
24011        .unwrap();
24012        let config = NodeServerConfig::new(
24013            DEFAULT_NODE_ENDPOINT,
24014            "fixture-token",
24015            NodeId::new("node-1").unwrap(),
24016            [workspace],
24017        )
24018        .unwrap();
24019        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24020    }
24021
24022    #[cfg(all(feature = "fixture", windows))]
24023    #[test]
24024    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24025        let root = temporary_workspace_root("provider-bundle-argv-hold");
24026        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24027        std::fs::create_dir_all(&root).unwrap();
24028        std::fs::create_dir_all(&outside).unwrap();
24029        let proof_path = root.join("provider.proof");
24030        let release_signal = root.join("release.signal");
24031        let config = NodeServerConfig::new(
24032            format!(
24033                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24034                std::process::id(),
24035            ),
24036            "fixture-token",
24037            NodeId::new("provider-bundle-hold-node").unwrap(),
24038            [WorkspaceConfig::new(
24039                WorkspaceId::new("primary").unwrap(),
24040                std::env::current_dir().unwrap(),
24041            )
24042            .unwrap()],
24043        )
24044        .unwrap()
24045        .with_spawn_profiles(SpawnProfileRegistry::new([
24046            crate::protocol::SpawnProfileDefaults {
24047                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24048                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24049                    .unwrap(),
24050                provider: agent("kimi"),
24051                mode: SessionMode::Pty,
24052                terminal_size: gate4agent_types::TerminalSize {
24053                    rows: 24,
24054                    columns: 80,
24055                },
24056                prompt: None,
24057                bundle_id: None,
24058                context_id: None,
24059                environment_profile_id: None,
24060            },
24061        ])
24062        .unwrap());
24063        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24064            config,
24065            agent("kimi"),
24066            proof_path.clone(),
24067            release_signal.clone(),
24068        )
24069        .unwrap();
24070        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24071
24072        let spec = NodeServer::provider_bundle_argv_fixture_spec(
24073            agent("kimi"),
24074            proof_path.clone(),
24075            Some(release_signal.clone()),
24076        )
24077        .unwrap();
24078        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24079        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24080        let release_wait = wrapper
24081            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24082            .unwrap();
24083        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24084        assert!(proof_write < release_wait);
24085        assert!(release_wait < fixture_ready);
24086        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24087        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24088        assert_eq!(
24089            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24090            &[
24091                proof_path.to_string_lossy().into_owned(),
24092                release_signal.to_string_lossy().into_owned(),
24093            ],
24094        );
24095
24096        let error = NodeServer::provider_bundle_argv_fixture_spec(
24097            agent("kimi"),
24098            proof_path,
24099            Some(outside.join("release.signal")),
24100        )
24101        .unwrap_err();
24102        assert!(matches!(
24103            error,
24104            NodeServerError::Registry(message)
24105                if message == "provider bundle argv release path must share the proof parent"
24106        ));
24107        drop(server);
24108        std::fs::remove_dir_all(root).unwrap();
24109        std::fs::remove_dir_all(outside).unwrap();
24110    }
24111
24112    #[cfg(feature = "fixture")]
24113    #[test]
24114    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24115        let root = temporary_workspace_root("context-fixture-registry");
24116        std::fs::create_dir_all(&root).unwrap();
24117        let proof_path = root.join("context.proof");
24118        let config = NodeServerConfig::new(
24119            format!(
24120                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24121                std::process::id(),
24122            ),
24123            "fixture-token",
24124            NodeId::new("context-fixture-node").unwrap(),
24125            [WorkspaceConfig::new(
24126                WorkspaceId::new("primary").unwrap(),
24127                std::env::current_dir().unwrap(),
24128            )
24129            .unwrap()],
24130        )
24131        .unwrap();
24132        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24133        let mut enabled = server
24134            .shared
24135            .enabled_providers
24136            .iter()
24137            .map(AgentId::as_str)
24138            .collect::<Vec<_>>();
24139        enabled.sort_unstable();
24140        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24141        for provider in &server.shared.enabled_providers {
24142            assert!(server
24143                .shared
24144                .provider_adapter_contracts
24145                .iter()
24146                .any(|contract| {
24147                    &contract.provider == provider
24148                        && contract.family == AdapterFamily::History
24149                }));
24150        }
24151        drop(server);
24152        std::fs::remove_dir_all(root).unwrap();
24153    }
24154
24155    #[cfg(feature = "fixture")]
24156    #[test]
24157    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24158        let root = temporary_workspace_root("monitoring-context-fixture");
24159        std::fs::create_dir_all(&root).unwrap();
24160        let proof_path = root.join("context.proof");
24161        let standard = NodeServer::context_pack_fixture_catalog(
24162            proof_path.clone(),
24163            None,
24164            false,
24165        ).unwrap();
24166        let monitoring = NodeServer::context_pack_fixture_catalog(
24167            proof_path.clone(),
24168            None,
24169            true,
24170        ).unwrap();
24171        let claude = AgentId::new("claude").unwrap();
24172        let standard_claude = standard.get(&claude).unwrap();
24173        let monitoring_claude = monitoring.get(&claude).unwrap();
24174        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
24175        // standard nor the monitoring fixture declares a hook adapter
24176        // anymore.
24177        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24178        assert_eq!(
24179            monitoring_claude.capabilities.adapters.history,
24180            standard_claude.capabilities.adapters.history,
24181        );
24182        assert_ne!(monitoring_claude.launch, standard_claude.launch);
24183        let codex = AgentId::new("codex").unwrap();
24184        let standard_codex = standard.get(&codex).unwrap();
24185        let monitoring_codex = monitoring.get(&codex).unwrap();
24186        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24187        assert_eq!(
24188            monitoring_codex.capabilities.adapters.history,
24189            standard_codex.capabilities.adapters.history,
24190        );
24191        assert_ne!(monitoring_codex.launch, standard_codex.launch);
24192        let codex_validation = monitoring_codex.launch.fixed_args.iter()
24193            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24194            .unwrap();
24195        for required in [
24196            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24197            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24198            "@('role', 'text')",
24199            "g4a-private-provider-session-canary",
24200            "private-provider-login-identity",
24201            "$rawProof",
24202            "$env:GATE4AGENT_HOOK_URL",
24203            "g4a-private-provider-session-canary",
24204        ] {
24205            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24206        }
24207        for provider in ["grok", "kimi"] {
24208            let provider = AgentId::new(provider).unwrap();
24209            assert_eq!(monitoring.get(&provider), standard.get(&provider));
24210        }
24211
24212        let config = NodeServerConfig::new(
24213            format!(
24214                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24215                std::process::id(),
24216            ),
24217            "fixture-token",
24218            NodeId::new("monitoring-context-fixture-node").unwrap(),
24219            [WorkspaceConfig::new(
24220                WorkspaceId::new("primary").unwrap(),
24221                std::env::current_dir().unwrap(),
24222            )
24223            .unwrap()],
24224        )
24225        .unwrap();
24226        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24227        assert!(server.shared.fixture_semantic_hook_policy);
24228        drop(server);
24229        std::fs::remove_dir_all(root).unwrap();
24230    }
24231
24232    #[cfg(feature = "fixture")]
24233    #[test]
24234    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24235        let root = temporary_workspace_root("kimi-context-only-fixture");
24236        std::fs::create_dir_all(&root).unwrap();
24237        let proof_path = root.join("context.proof");
24238        let standard = NodeServer::context_pack_fixture_catalog(
24239            proof_path.clone(),
24240            None,
24241            false,
24242        ).unwrap();
24243        let kimi = AgentId::new("kimi").unwrap();
24244        let context_only = NodeServer::context_pack_fixture_catalog(
24245            proof_path,
24246            Some(&kimi),
24247            false,
24248        )
24249        .unwrap();
24250
24251        let codex = AgentId::new("codex").unwrap();
24252        assert_eq!(
24253            standard.get(&codex).unwrap().launch.fixed_args,
24254            context_only.get(&codex).unwrap().launch.fixed_args,
24255        );
24256        let script = context_only
24257            .get(&kimi)
24258            .unwrap()
24259            .launch
24260            .fixed_args
24261            .iter()
24262            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24263            .expect("Kimi context-only validation script is missing");
24264        for required in [
24265            "$bundleArgs.Count -ne 0",
24266            "GATE4AGENT_CONTEXT_ROOT",
24267            "context-pack.json",
24268            "$entries.Count -ne 1",
24269            "g4a-context-pack-v1",
24270            "$document.PSObject.Properties['cwd']",
24271            "'claude', 'codex', 'grok', 'kimi'",
24272            "$hasUser",
24273            "$hasAssistant",
24274            "SHA256",
24275            "context-only",
24276        ] {
24277            assert!(script.contains(required), "missing validation: {required}");
24278        }
24279        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24280            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24281        }
24282        assert!(!standard
24283            .get(&kimi)
24284            .unwrap()
24285            .launch
24286            .fixed_args
24287            .iter()
24288            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24289
24290        std::fs::remove_dir_all(root).unwrap();
24291    }
24292
24293    #[cfg(all(feature = "fixture", windows))]
24294    #[tokio::test]
24295    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24296        let root = temporary_workspace_root("kimi-context-only-spawn");
24297        let workspace_root = root.join("workspace");
24298        std::fs::create_dir_all(&workspace_root).unwrap();
24299        let materialization_root = root.join("private-materializations");
24300        let proof_path = root.join("context.proof");
24301        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24302        let workspace_id = WorkspaceId::new("primary").unwrap();
24303        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24304        let endpoint = format!(
24305            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24306            std::process::id(),
24307            unix_time_ms(),
24308        );
24309        let spawn_profiles = SpawnProfileRegistry::new([
24310            crate::protocol::SpawnProfileDefaults {
24311                profile_id: profile_id.clone(),
24312                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24313                provider: agent("kimi"),
24314                mode: SessionMode::Pty,
24315                terminal_size: gate4agent_types::TerminalSize {
24316                    rows: 30,
24317                    columns: 120,
24318                },
24319                prompt: None,
24320                bundle_id: None,
24321                context_id: None,
24322                environment_profile_id: None,
24323            },
24324        ])
24325        .unwrap();
24326        let mut config = NodeServerConfig::new(
24327            &endpoint,
24328            "fixture-token",
24329            node_id.clone(),
24330            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24331        )
24332        .unwrap()
24333        .with_spawn_profiles(spawn_profiles)
24334        .with_session_environment_materialization(
24335            materialization_root,
24336            Arc::new(FixtureSecretResolver {
24337                deny: Arc::new(AtomicBool::new(false)),
24338            }),
24339        )
24340        .unwrap();
24341        config.fixture_raw_pty_runtime = true;
24342        let server = NodeServer::new_context_only_proof_fixture(
24343            config,
24344            agent("kimi"),
24345            proof_path.clone(),
24346        )
24347        .unwrap();
24348        let shared = Arc::clone(&server.shared);
24349        let shutdown = server.shutdown_handle();
24350
24351        let history = HistorySessionRecord {
24352            session_id: "context-only-source-history".to_owned(),
24353            title: None,
24354            cwd: None,
24355            model: None,
24356            message_count: 2,
24357            completed_turn_count: None,
24358            total_tokens: 0,
24359            messages: vec![
24360                gate4agent_types::HistoryMessageRecord {
24361                    role: gate4agent_types::HistoryMessageRole::User,
24362                    text: "continue the bounded parent run through native C2".to_owned(),
24363                },
24364                gate4agent_types::HistoryMessageRecord {
24365                    role: gate4agent_types::HistoryMessageRole::Assistant,
24366                    text: "the exact parent context is ready for the child run".to_owned(),
24367                },
24368            ],
24369        };
24370        let pack = NodeContextPack::export(
24371            ContextPackLineageReceipt {
24372                source_node_id: node_id.clone(),
24373                source_session: SessionAddress {
24374                    workspace_id: workspace_id.clone(),
24375                    session: SessionKey {
24376                        instance_id: AgentInstanceId(71),
24377                        generation: SessionGeneration(1),
24378                    },
24379                },
24380                source_provider: agent("codex"),
24381            },
24382            &history,
24383        )
24384        .unwrap();
24385        let expected_context_hash = digest(&SHA256, pack.bytes())
24386            .as_ref()
24387            .iter()
24388            .map(|byte| format!("{byte:02x}"))
24389            .collect::<String>();
24390        let context = shared
24391            .context_catalog
24392            .write()
24393            .unwrap_or_else(|poisoned| poisoned.into_inner())
24394            .insert(pack)
24395            .unwrap();
24396        let spec = SpawnSpec {
24397            target: crate::protocol::SpawnTarget {
24398                node_id,
24399                workspace_id: workspace_id.clone(),
24400                worktree_id: None,
24401            },
24402            profile_id,
24403            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24404                "target-kimi-r1",
24405            ).unwrap(),
24406            overrides: crate::protocol::SpawnOverrides {
24407                context_id: crate::protocol::SpawnOverride::Set {
24408                    value: context.id.clone(),
24409                },
24410                ..crate::protocol::SpawnOverrides::default()
24411            },
24412            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24413            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24414            required_capabilities: SpawnRequiredCapabilities::default(),
24415        };
24416        let resolved = match shared.resolve_spawn_spec(&spec) {
24417            Ok(resolved) => resolved,
24418            Err(error) => panic!(
24419                "Kimi context-only resolve rejected: exact_code={:?} message={}",
24420                error.code,
24421                error.message,
24422            ),
24423        };
24424        let resolved_context = match shared.resolve_context(&resolved) {
24425            Ok(Some(resolved_context)) => resolved_context,
24426            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24427            Err(error) => panic!(
24428                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24429                error.code,
24430                error.message,
24431            ),
24432        };
24433        assert_eq!(resolved_context, context);
24434        let runtime_policy = match shared
24435            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24436            .await
24437        {
24438            Ok(runtime_policy) => runtime_policy,
24439            Err(error) => panic!(
24440                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24441                error.code,
24442                error.message,
24443            ),
24444        };
24445        assert!(runtime_policy.raw_pty_lifecycle);
24446
24447        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24448        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24449            agent("kimi"),
24450            TransportKind::Pty,
24451            vec![EnvMutation {
24452                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24453                value: Some(OsString::from("must-remain-rejected")),
24454            }],
24455        )
24456        .unwrap();
24457        assert_eq!(
24458            launch_control
24459                .install_native_launch_environment_overlay(
24460                    AgentInstanceId(72),
24461                    generic_overlay,
24462                )
24463                .unwrap_err(),
24464            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24465        );
24466
24467        let diagnostic_address = SessionAddress {
24468            workspace_id: workspace_id.clone(),
24469            session: SessionKey {
24470                instance_id: AgentInstanceId(73),
24471                generation: SessionGeneration(1),
24472            },
24473        };
24474        let (diagnostic_overlay, diagnostic_materialization) = match shared
24475            .prepare_session_materialization(
24476                &diagnostic_address,
24477                &agent("kimi"),
24478                SessionMode::Pty,
24479                None,
24480                None,
24481                Some(&resolved_context),
24482                None,
24483            )
24484        {
24485            Ok(Some(prepared)) => prepared,
24486            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24487            Err(error) => panic!(
24488                "Kimi context-only materialization rejected: exact_code={:?} message={}",
24489                error.code,
24490                error.message,
24491            ),
24492        };
24493        let diagnostic_overlay = diagnostic_overlay
24494            .expect("Kimi context-only materialization omitted its environment overlay");
24495        assert!(matches!(
24496            &diagnostic_overlay,
24497            PreparedNativeLaunchOverlay::Environment(_),
24498        ));
24499        match shared.install_prepared_launch_overlay(
24500            diagnostic_address.session.instance_id,
24501            diagnostic_overlay,
24502        ) {
24503            Ok(None) => {}
24504            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24505            Err(error) => panic!(
24506                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24507                error.code,
24508                error.message,
24509            ),
24510        }
24511        assert!(launch_control.clear_native_instance_launch_overlay(
24512            diagnostic_address.session.instance_id,
24513        ));
24514        drop(diagnostic_materialization);
24515        assert!(shared
24516            .materializations
24517            .lock()
24518            .unwrap_or_else(|poisoned| poisoned.into_inner())
24519            .is_empty());
24520
24521        let node_task = tokio::spawn(server.run());
24522        let ready_client = timeout(Duration::from_secs(5), async {
24523            loop {
24524                match gate4agent_node_wire::LocalNodeClient::connect(
24525                    &endpoint,
24526                    &shared.node_id,
24527                    ClientRole::Observer,
24528                    "fixture-token",
24529                )
24530                .await
24531                {
24532                    Ok(client) => break client,
24533                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24534                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24535                }
24536            }
24537        })
24538        .await
24539        .expect("Kimi context-only Node did not become ready");
24540        assert_eq!(
24541            ready_client
24542                .hello()
24543                .snapshot
24544                .provider_runtime_statuses
24545                .iter()
24546                .find(|status| status.provider().as_str() == "kimi")
24547                .expect("Kimi runtime status is missing")
24548                .mode(),
24549            crate::protocol::ProviderRuntimeMode::RawPassthrough,
24550        );
24551        drop(ready_client);
24552
24553        let receipt = match shared.spawn_from_spec(spec).await {
24554            Ok(receipt) => receipt,
24555            Err(error) => panic!(
24556                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24557                error.code,
24558                error.message,
24559            ),
24560        };
24561        assert_eq!(receipt.provider, agent("kimi"));
24562        assert_eq!(receipt.context.as_ref(), Some(&context));
24563        assert!(receipt.context_binding_is_valid());
24564        timeout(Duration::from_secs(5), async {
24565            while !proof_path.is_file() {
24566                sleep(Duration::from_millis(10)).await;
24567            }
24568        })
24569        .await
24570        .expect("Kimi context-only fixture did not write its proof");
24571
24572        let proof = std::fs::read_to_string(&proof_path).unwrap();
24573        let lines = proof.lines().collect::<Vec<_>>();
24574        assert_eq!(lines.len(), 7);
24575        assert_eq!(lines[0], "context-only");
24576        assert!(Path::new(lines[1]).is_absolute());
24577        assert_ne!(
24578            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24579            std::fs::canonicalize(&workspace_root).unwrap(),
24580        );
24581        assert_eq!(
24582            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24583            std::fs::canonicalize(&workspace_root).unwrap(),
24584        );
24585        assert_eq!(lines[3], expected_context_hash);
24586        assert_eq!(lines[4], "g4a-context-pack-v1");
24587        assert_eq!(lines[5], "codex");
24588        assert_eq!(lines[6], "2");
24589        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24590            session.instance_id == receipt.session.session.instance_id
24591                && session.agent_id.as_str() == "kimi"
24592        }));
24593
24594        shutdown.request_shutdown().await.unwrap();
24595        timeout(Duration::from_secs(5), node_task)
24596            .await
24597            .unwrap()
24598            .unwrap()
24599            .unwrap();
24600        drop(shutdown);
24601        drop(shared);
24602        std::fs::remove_dir_all(root).unwrap();
24603    }
24604
24605    #[cfg(feature = "fixture")]
24606    #[test]
24607    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24608        let root = temporary_workspace_root("clean-exit-constructor");
24609        std::fs::create_dir_all(&root).unwrap();
24610        let config = construction_test_config("clean-exit-constructor")
24611            .with_state_path(root.join("node-state.json"))
24612            .unwrap();
24613        let server = NodeServer::new_clean_exit_fixture(
24614            config,
24615            root.clone(),
24616            root.join("started.marker"),
24617            root.join("release.signal"),
24618        )
24619        .unwrap();
24620        assert_eq!(
24621            server
24622                .shared
24623                .enabled_providers
24624                .iter()
24625                .map(AgentId::as_str)
24626                .collect::<Vec<_>>(),
24627            ["claude"],
24628        );
24629        drop(server);
24630        std::fs::remove_dir_all(root).unwrap();
24631    }
24632
24633    #[test]
24634    fn production_registry_provider_contract_manifest_is_exact() {
24635        let registry = active_registry().unwrap();
24636        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24637        assert_eq!(
24638            providers
24639                .iter()
24640                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24641                .collect::<Vec<_>>(),
24642            vec![
24643                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24644                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24645                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24646                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24647            ]
24648        );
24649        assert_eq!(
24650            adapters
24651                .iter()
24652                .map(|contract| (
24653                    contract.provider.as_str(),
24654                    contract.family,
24655                    contract.adapter_id.as_str(),
24656                    contract.revision.as_str(),
24657                ))
24658                .collect::<Vec<_>>(),
24659            vec![
24660                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24661                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24662                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24663                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24664                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24665                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24666                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24667                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24668                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24669                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24670                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24671                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24672                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24673                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24674                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24675                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24676                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24677                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24678                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24679                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24680                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24681                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24682                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24683            ]
24684        );
24685    }
24686
24687    fn construction_test_config(name: &str) -> NodeServerConfig {
24688        let workspace = WorkspaceConfig::new(
24689            WorkspaceId::new("primary").unwrap(),
24690            std::env::current_dir().unwrap(),
24691        )
24692        .unwrap();
24693        NodeServerConfig::new(
24694            format!(r"\\.\pipe\gate4agent-{name}"),
24695            "fixture-token",
24696            NodeId::new(name).unwrap(),
24697            [workspace],
24698        )
24699        .unwrap()
24700    }
24701
24702    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24703        let adapter = AdapterBinding::new(
24704            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24705            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24706            gate4agent_types::AdapterVerification::SyntheticFixture,
24707        )
24708        .unwrap();
24709        let agent_ids = ["claude", "codex", "kimi"]
24710            .into_iter()
24711            .map(|agent_id| AgentId::new(agent_id).unwrap())
24712            .collect::<Vec<_>>();
24713        let adapters = gate4agent_catalog::AdapterRegistry::new(
24714            [
24715                AdapterFamily::PtySemantic,
24716                AdapterFamily::Pipe,
24717                AdapterFamily::OneShot,
24718                AdapterFamily::Acp,
24719                AdapterFamily::Hook,
24720                AdapterFamily::ManagedHook,
24721                AdapterFamily::History,
24722                AdapterFamily::Resume,
24723                AdapterFamily::SessionOptions,
24724                AdapterFamily::CapabilityProbe,
24725            ]
24726            .into_iter()
24727            .map(|family| gate4agent_catalog::AdapterDescriptor {
24728                family,
24729                binding: adapter.clone(),
24730                agents: agent_ids.clone(),
24731            }),
24732        )
24733        .unwrap();
24734        let specs = agent_ids
24735            .iter()
24736            .map(|agent_id| {
24737                let agent_id = agent_id.as_str();
24738                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24739                spec.revision =
24740                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24741                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24742                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24743                    adapter: adapter.clone(),
24744                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24745                    launch_override: None,
24746                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24747                });
24748                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24749                    adapter: adapter.clone(),
24750                    launch_override: None,
24751                });
24752                spec.capabilities.adapters.hook = Some(adapter.clone());
24753                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24754                spec.capabilities.adapters.one_shot = Some(adapter.clone());
24755                spec.capabilities.adapters.history = Some(adapter.clone());
24756                spec.capabilities.adapters.resume = Some(adapter.clone());
24757                spec.capabilities.adapters.session_options = Some(adapter.clone());
24758                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24759                spec
24760            })
24761            .collect::<Vec<_>>();
24762        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24763    }
24764
24765    /// The real fleet's manifest is accepted by a real server construction.
24766    ///
24767    /// The count is asserted against `active_registry` itself rather than a
24768    /// literal, so this stays a test that construction succeeds for whatever
24769    /// the fleet is -- which is the property worth guarding -- instead of a
24770    /// second place the fleet size is written down and has to be kept in
24771    /// step with the first.
24772    #[test]
24773    fn node_server_construction_accepts_the_production_manifest() {
24774        let registry = active_registry().unwrap();
24775        let (providers, _) = provider_contract_manifest(&registry).unwrap();
24776        assert_eq!(providers.len(), registry.iter().count());
24777        assert!(!providers.is_empty());
24778        let server = NodeServer::new_with_registry(
24779            construction_test_config("manifest-production-bound"),
24780            registry,
24781        )
24782        .unwrap();
24783        drop(server);
24784    }
24785
24786    #[test]
24787    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24788        let registry = maximum_size_provider_manifest_registry();
24789        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24790        assert_eq!(providers.len(), 3);
24791        assert_eq!(adapters.len(), 30);
24792        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24793        let error = match NodeServer::new_with_registry(
24794            construction_test_config("manifest-handshake-overflow"),
24795            registry,
24796        ) {
24797            Ok(_) => panic!("oversized negotiated manifest was accepted"),
24798            Err(error) => error,
24799        };
24800        assert!(matches!(
24801            error,
24802            NodeServerError::ProviderContractManifest(message)
24803                if message.contains("authentication binding length")
24804                    && message.contains("8192-byte limit")
24805        ));
24806    }
24807
24808    #[test]
24809    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24810        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24811        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24812        let registry = AgentRegistry::new([spec]).unwrap();
24813        assert!(matches!(
24814            provider_contract_manifest(&registry),
24815            Err(NodeServerError::ProviderContractManifest(message))
24816                if message.contains("exceeds the 128-byte limit")
24817        ));
24818    }
24819
24820    #[test]
24821    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24822        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24823        spec.revision = "invalid revision".to_owned();
24824        let registry = AgentRegistry::new([spec]).unwrap();
24825        assert!(matches!(
24826            provider_contract_manifest(&registry),
24827            Err(NodeServerError::ProviderContractManifest(message))
24828                if message.contains("bounded lowercase ASCII")
24829        ));
24830    }
24831
24832    #[test]
24833    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24834        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24835        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24836        let conflicting_binding = builtin_registry()
24837            .get_by_id("codex")
24838            .unwrap()
24839            .capabilities
24840            .adapters
24841            .one_shot
24842            .clone()
24843            .unwrap();
24844        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24845        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24846        pipe.adapter = transport_binding;
24847        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24848        let registry = AgentRegistry::new([spec]).unwrap();
24849        assert!(matches!(
24850            provider_contract_manifest(&registry),
24851            Err(NodeServerError::ProviderContractManifest(message))
24852                if message.contains("conflicting OneShot adapter bindings")
24853        ));
24854    }
24855
24856    #[test]
24857    fn public_node_failures_expose_only_fixed_categories() {
24858        let provider = failure(
24859            NodeFailureCode::BackendOperationFailed,
24860            r"provider bearer-secret failed at C:\private\session.jsonl",
24861        );
24862        assert_eq!(provider.message, "backend-operation-failed");
24863        assert!(!provider.message.contains("bearer-secret"));
24864        assert!(!provider.message.contains(r"C:\private"));
24865        let persistence = persistence_failure(io::Error::new(
24866            io::ErrorKind::Other,
24867            r"state write failed at C:\private\state-v1.json",
24868        ));
24869        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24870        assert!(!persistence.message.contains(r"C:\private"));
24871
24872        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24873            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24874            assert_eq!(
24875                generic.to_string(),
24876                "node durable state failed: durable-state-load-failed",
24877            );
24878        }
24879    }
24880
24881    #[test]
24882    fn terminal_byte_sequences_are_bounded_before_dispatch() {
24883        let sequence = b"\x1b[1;5D".to_vec();
24884        assert_eq!(
24885            terminal_bytes_action(sequence.clone()).unwrap(),
24886            InputAction::TerminalBytes(sequence),
24887        );
24888        assert_eq!(
24889            terminal_bytes_action(Vec::new()).unwrap_err().code,
24890            NodeFailureCode::InvalidRequest,
24891        );
24892        assert_eq!(
24893            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24894                .unwrap_err()
24895                .code,
24896            NodeFailureCode::InvalidRequest,
24897        );
24898    }
24899
24900    #[test]
24901    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24902        let root = temporary_workspace_root("exclusive-state-owner");
24903        std::fs::create_dir_all(&root).unwrap();
24904        let workspace = WorkspaceConfig::new(
24905            WorkspaceId::new("primary").unwrap(),
24906            &root,
24907        )
24908        .unwrap();
24909        let state_path = root.join("state-v1.json");
24910        let endpoint = format!(
24911            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24912            std::process::id(),
24913            std::time::SystemTime::now()
24914                .duration_since(std::time::UNIX_EPOCH)
24915                .unwrap()
24916                .as_nanos(),
24917        );
24918        let config = NodeServerConfig::new(
24919            endpoint,
24920            "fixture-token",
24921            NodeId::new("node-1").unwrap(),
24922            [workspace],
24923        )
24924        .unwrap()
24925        .with_state_path(&state_path)
24926        .unwrap();
24927
24928        let first = NodeServer::new(config.clone()).unwrap();
24929        let error = match NodeServer::new(config.clone()) {
24930            Ok(_) => panic!("a second node server acquired the same durable state path"),
24931            Err(error) => error,
24932        };
24933        assert!(matches!(
24934            &error,
24935            NodeServerError::DurableState(error)
24936                if error.kind() == io::ErrorKind::WouldBlock
24937        ));
24938        assert_eq!(
24939            error.to_string(),
24940            "node durable state failed: durable-state-lock-failed",
24941        );
24942        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24943        drop(first);
24944        let second = NodeServer::new(config).unwrap();
24945        drop(second);
24946        std::fs::remove_dir_all(root).unwrap();
24947    }
24948
24949    #[test]
24950    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24951        let catalog = active_registry().unwrap();
24952        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24953        let workspace_id = WorkspaceId::new("primary").unwrap();
24954        let workspace = WorkspaceConfig::new(
24955            workspace_id.clone(),
24956            std::env::current_dir().unwrap(),
24957        )
24958        .unwrap();
24959        let shared = NodeShared::new(
24960            handle,
24961            "fixture-token".to_owned(),
24962            NodeId::new("node-1").unwrap(),
24963            vec![workspace.clone()],
24964            vec![agent("claude"), agent("codex"), agent("kimi")],
24965        );
24966        let instance_id = AgentInstanceId(17);
24967        let generation = SessionGeneration(3);
24968        assert!(shared.address_for(instance_id, generation).is_none());
24969        shared.bind_session(instance_id, workspace_id.clone(), generation);
24970        let address = SessionAddress {
24971            workspace_id,
24972            session: SessionKey { instance_id, generation },
24973        };
24974        assert_eq!(
24975            shared.workspace_root(&address.workspace_id).unwrap(),
24976            workspace.canonical_root,
24977        );
24978        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
24979        shared.remove_binding(&address);
24980        assert!(shared.address_for(instance_id, generation).is_none());
24981    }
24982
24983    #[test]
24984    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
24985        let address = SessionAddress {
24986            workspace_id: WorkspaceId::new("primary").unwrap(),
24987            session: SessionKey {
24988                instance_id: AgentInstanceId(7),
24989                generation: SessionGeneration(2),
24990            },
24991        };
24992        let record_id = SessionRecordId::new("record-a").unwrap();
24993        let size = gate4agent_types::TerminalSize {
24994            rows: 24,
24995            columns: 80,
24996        };
24997        let address_requests = [
24998            NodeRequest::Resume {
24999                session: address.clone(),
25000                terminal_size: size,
25001                initial_prompt: None,
25002            },
25003            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25004            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25005            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25006            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25007            NodeRequest::TerminalControl {
25008                session: address.clone(),
25009                control: TerminalControl::Enter,
25010            },
25011            NodeRequest::Resize { session: address.clone(), size },
25012            NodeRequest::Interrupt { session: address.clone() },
25013            NodeRequest::Stop { session: address.clone(), force: false },
25014            NodeRequest::Remove { session: address },
25015        ];
25016        for request in &address_requests {
25017            assert!(request_requires_open_provider_ids_with(
25018                request,
25019                |_| Some(agent("third-party-agent")),
25020                |_| Some(agent("claude")),
25021            ));
25022            assert!(!request_requires_open_provider_ids_with(
25023                request,
25024                |_| Some(agent("claude")),
25025                |_| Some(agent("claude")),
25026            ));
25027            assert!(request_requires_open_provider_ids_with(
25028                request,
25029                |_| None,
25030                |_| Some(agent("claude")),
25031            ));
25032        }
25033
25034        let record_requests = [
25035            NodeRequest::RenameSessionRecord {
25036                record_id: record_id.clone(),
25037                display_name: "renamed".to_owned(),
25038            },
25039            NodeRequest::ResumeSessionRecord {
25040                record_id: record_id.clone(),
25041                terminal_size: size,
25042                initial_prompt: None,
25043            },
25044            NodeRequest::ForgetSessionRecord { record_id },
25045        ];
25046        for request in &record_requests {
25047            assert!(request_requires_open_provider_ids_with(
25048                request,
25049                |_| Some(agent("claude")),
25050                |_| Some(agent("grok")),
25051            ));
25052            assert!(!request_requires_open_provider_ids_with(
25053                request,
25054                |_| Some(agent("claude")),
25055                |_| Some(agent("kimi")),
25056            ));
25057            assert!(request_requires_open_provider_ids_with(
25058                request,
25059                |_| Some(agent("claude")),
25060                |_| None,
25061            ));
25062        }
25063
25064        assert!(request_requires_open_provider_ids_with(
25065            &NodeRequest::Spawn {
25066                workspace_id: WorkspaceId::new("primary").unwrap(),
25067                provider: agent("third-party-agent"),
25068                mode: SessionMode::Pty,
25069                terminal_size: size,
25070                initial_prompt: None,
25071            },
25072            |_| None,
25073            |_| None,
25074        ));
25075        assert!(!request_requires_open_provider_ids_with(
25076            &NodeRequest::Snapshot,
25077            |_| None,
25078            |_| None,
25079        ));
25080        assert!(request_requires_open_provider_ids_with(
25081            &NodeRequest::ForgetContextPack {
25082                context_id: SpawnContextId::new("ctx-forget").unwrap(),
25083            },
25084            |_| None,
25085            |_| None,
25086        ));
25087    }
25088
25089    #[test]
25090    fn legacy_projection_filters_open_record_replies_and_event_history() {
25091        let catalog = active_registry().unwrap();
25092        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25093        let workspace = WorkspaceConfig::new(
25094            WorkspaceId::new("primary").unwrap(),
25095            std::env::current_dir().unwrap(),
25096        )
25097        .unwrap();
25098        let shared = NodeShared::new(
25099            handle,
25100            "fixture-token".to_owned(),
25101            NodeId::new("node-1").unwrap(),
25102            vec![workspace],
25103            vec![agent("claude"), agent("grok")],
25104        );
25105        let legacy = record("claude", "legacy-record");
25106        let open = record("grok", "open-record");
25107        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25108            record: legacy.clone(),
25109        });
25110        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25111            record: open.clone(),
25112        });
25113        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25114            record_id: legacy.record_id.clone(),
25115        });
25116        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25117            record_id: open.record_id.clone(),
25118        });
25119        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25120        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25121        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25122        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25123
25124        let mut record_reply = ResponseEnvelope {
25125            request_id: 1,
25126            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25127        };
25128        project_response_legacy_provider_ids(&shared, &mut record_reply);
25129        assert!(matches!(
25130            record_reply.result,
25131            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25132        ));
25133
25134        let mut resync = ResponseEnvelope {
25135            request_id: 2,
25136            result: Ok(NodeResponse::Resync {
25137                event_sequence: open_removed.sequence,
25138                oldest_available_sequence: 1,
25139                snapshot: shared.snapshot(),
25140                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25141            }),
25142        };
25143        project_response_legacy_provider_ids(&shared, &mut resync);
25144        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25145            panic!("legacy projection changed the resync response kind");
25146        };
25147        assert!(snapshot.session_records.iter().all(|item| {
25148            provider_id_is_legacy(&item.provider)
25149        }));
25150        assert_eq!(events.len(), 2);
25151        assert!(events.iter().all(|event| matches!(
25152            &event.event,
25153            NodeEvent::SessionRecordUpserted { record }
25154                if provider_id_is_legacy(&record.provider)
25155        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25156    }
25157
25158    #[test]
25159    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25160        let catalog = active_registry().unwrap();
25161        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25162        let workspace_id = WorkspaceId::new("primary").unwrap();
25163        let workspace = WorkspaceConfig::new(
25164            workspace_id.clone(),
25165            std::env::current_dir().unwrap(),
25166        )
25167        .unwrap();
25168        let shared = NodeShared::new(
25169            handle,
25170            "fixture-token".to_owned(),
25171            NodeId::new("node-1").unwrap(),
25172            vec![workspace],
25173            vec![agent("claude")],
25174        );
25175        for index in 0..CONTROL_SESSIONS_MAX {
25176            shared.bind_session(
25177                AgentInstanceId((index + 1) as u64),
25178                workspace_id.clone(),
25179                SessionGeneration(1),
25180            );
25181        }
25182        let error = shared.ensure_binding_capacity().unwrap_err();
25183        assert_eq!(error.code, NodeFailureCode::BackendBusy);
25184    }
25185
25186    #[tokio::test]
25187    async fn provider_spawn_admission_rejects_before_session_mutation() {
25188        let catalog = active_registry().unwrap();
25189        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25190        let workspace = WorkspaceConfig::new(
25191            WorkspaceId::new("primary").unwrap(),
25192            std::env::current_dir().unwrap(),
25193        )
25194        .unwrap();
25195        let mut shared = NodeShared::new(
25196            handle,
25197            "fixture-token".to_owned(),
25198            NodeId::new("node-provider-admission").unwrap(),
25199            vec![workspace],
25200            vec![agent("claude")],
25201        );
25202        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25203            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25204        ])
25205        .unwrap();
25206        let terminal_size = gate4agent_types::TerminalSize {
25207            rows: 24,
25208            columns: 80,
25209        };
25210
25211        let unavailable = shared
25212            .spawn_session(
25213                WorkspaceId::new("primary").unwrap(),
25214                agent("claude"),
25215                SessionMode::Pty,
25216                terminal_size,
25217                None,
25218            )
25219            .await
25220            .unwrap_err();
25221        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25222        assert_eq!(unavailable.message, "backend-operation-failed");
25223
25224        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25225            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25226                agent("claude"),
25227                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25228            ),
25229        ])
25230        .unwrap();
25231        let semantic = shared
25232            .spawn_session(
25233                WorkspaceId::new("primary").unwrap(),
25234                agent("claude"),
25235                SessionMode::Inline,
25236                terminal_size,
25237                None,
25238            )
25239            .await
25240            .unwrap_err();
25241        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25242        assert_eq!(semantic.message, "unsupported-capability");
25243
25244        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25245        assert!(shared
25246            .session_bindings
25247            .lock()
25248            .unwrap_or_else(|poisoned| poisoned.into_inner())
25249            .is_empty());
25250        assert!(shared
25251            .session_records
25252            .lock()
25253            .unwrap_or_else(|poisoned| poisoned.into_inner())
25254            .records
25255            .is_empty());
25256        assert!(shared.handle.snapshot().sessions.is_empty());
25257    }
25258
25259    #[tokio::test]
25260    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25261        let catalog = active_registry().unwrap();
25262        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25263        let workspace_id = WorkspaceId::new("primary").unwrap();
25264        let workspace = WorkspaceConfig::new(
25265            workspace_id.clone(),
25266            std::env::current_dir().unwrap(),
25267        )
25268        .unwrap();
25269        let shared = NodeShared::new(
25270            handle,
25271            "fixture-token".to_owned(),
25272            NodeId::new("node-raw-spawn-record").unwrap(),
25273            vec![workspace],
25274            vec![agent("claude")],
25275        );
25276        let address = SessionAddress {
25277            workspace_id,
25278            session: SessionKey {
25279                instance_id: AgentInstanceId(71),
25280                generation: SessionGeneration::default(),
25281            },
25282        };
25283
25284        let record_id = shared
25285            .bind_spawn_session(
25286                &address,
25287                agent("claude"),
25288                SessionMode::Pty,
25289                ProviderRuntimePolicy::raw_pty(),
25290                SpawnRecordPolicy::Always,
25291                None,
25292            )
25293            .unwrap()
25294            .unwrap();
25295        shared
25296            .handle
25297            .dispatch(shared.prepare_command(ControlCommand::Register {
25298                instance_id: address.session.instance_id,
25299                agent_id: agent("claude"),
25300                transport: TransportKind::Pty,
25301            }))
25302            .unwrap();
25303        runtime.tick().await;
25304
25305        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25306            session.instance_id == address.session.instance_id
25307                && session.generation == address.session.generation
25308        }));
25309        let record = shared.record(&record_id).unwrap();
25310        assert_eq!(record.display_name, "claude #1");
25311        assert_eq!(record.state, ManagedSessionState::Live);
25312        assert_eq!(record.active_session.as_ref(), Some(&address));
25313        assert!(record.provider_session.is_none());
25314        let binding_record_id = shared
25315            .session_bindings
25316            .lock()
25317            .unwrap_or_else(|poisoned| poisoned.into_inner())
25318            .get(&address.session.instance_id)
25319            .and_then(|binding| binding.record_id.clone());
25320        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25321
25322        let renamed = shared
25323            .rename_session_record(&record_id, "raw review".to_owned())
25324            .unwrap();
25325        assert_eq!(renamed.record_id, record_id);
25326        assert_eq!(renamed.display_name, "raw review");
25327        shared.reconcile_managed_record(
25328            &record_id,
25329            &address,
25330            &ControlEvent {
25331                sequence: 1,
25332                command_id: None,
25333                instance_id: address.session.instance_id,
25334                generation: address.session.generation,
25335                event: ControlEventKind::Running { process_id: None },
25336            },
25337            false,
25338            false,
25339        );
25340        let running = shared.record(&record_id).unwrap();
25341        assert_eq!(running.state, ManagedSessionState::Live);
25342        assert_eq!(running.active_session.as_ref(), Some(&address));
25343        assert_eq!(running.display_name, "raw review");
25344
25345        shared.reconcile_managed_record(
25346            &record_id,
25347            &address,
25348            &ControlEvent {
25349                sequence: 2,
25350                command_id: None,
25351                instance_id: address.session.instance_id,
25352                generation: address.session.generation,
25353                event: ControlEventKind::Exited {
25354                    exit_code: Some(0),
25355                    forced: false,
25356                },
25357            },
25358            false,
25359            false,
25360        );
25361        let detached = shared.record(&record_id).unwrap();
25362        assert_eq!(detached.state, ManagedSessionState::Unavailable);
25363        assert!(detached.active_session.is_none());
25364        assert_eq!(detached.display_name, "raw review");
25365        let renamed_detached = shared
25366            .rename_session_record(&record_id, "raw review detached".to_owned())
25367            .unwrap();
25368        assert_eq!(renamed_detached.record_id, record_id);
25369        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25370        assert!(renamed_detached.active_session.is_none());
25371        assert_eq!(renamed_detached.display_name, "raw review detached");
25372        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25373    }
25374
25375    #[test]
25376    fn legacy_raw_spawn_binding_remains_untracked() {
25377        let catalog = active_registry().unwrap();
25378        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25379        let workspace_id = WorkspaceId::new("primary").unwrap();
25380        let workspace = WorkspaceConfig::new(
25381            workspace_id.clone(),
25382            std::env::current_dir().unwrap(),
25383        )
25384        .unwrap();
25385        let shared = NodeShared::new(
25386            handle,
25387            "fixture-token".to_owned(),
25388            NodeId::new("node-legacy-raw-spawn").unwrap(),
25389            vec![workspace],
25390            vec![agent("claude")],
25391        );
25392        let address = SessionAddress {
25393            workspace_id,
25394            session: SessionKey {
25395                instance_id: AgentInstanceId(72),
25396                generation: SessionGeneration::default(),
25397            },
25398        };
25399
25400        let record_id = shared
25401            .bind_spawn_session(
25402                &address,
25403                agent("claude"),
25404                SessionMode::Pty,
25405                ProviderRuntimePolicy::raw_pty(),
25406                SpawnRecordPolicy::ProviderIdentityOnly,
25407                None,
25408            )
25409            .unwrap();
25410
25411        assert!(record_id.is_none());
25412        let binding_record_id = shared
25413            .session_bindings
25414            .lock()
25415            .unwrap_or_else(|poisoned| poisoned.into_inner())
25416            .get(&address.session.instance_id)
25417            .and_then(|binding| binding.record_id.clone());
25418        assert!(binding_record_id.is_none());
25419        assert!(shared.snapshot().session_records.is_empty());
25420    }
25421
25422    #[tokio::test]
25423    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25424        let catalog = active_registry().unwrap();
25425        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25426        let workspace_id = WorkspaceId::new("primary").unwrap();
25427        let workspace = WorkspaceConfig::new(
25428            workspace_id.clone(),
25429            std::env::current_dir().unwrap(),
25430        )
25431        .unwrap();
25432        let mut shared = NodeShared::new(
25433            handle,
25434            "fixture-token".to_owned(),
25435            NodeId::new("node-raw-semantic-gate").unwrap(),
25436            vec![workspace],
25437            vec![agent("claude")],
25438        );
25439        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25440            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25441        ])
25442        .unwrap();
25443        shared
25444            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25445            .unwrap();
25446        let address = SessionAddress {
25447            workspace_id,
25448            session: SessionKey {
25449                instance_id: AgentInstanceId(72),
25450                generation: SessionGeneration::default(),
25451            },
25452        };
25453        shared.bind_session_with_policy(
25454            &address,
25455            ProviderRuntimePolicy::raw_pty(),
25456            None,
25457        );
25458        shared
25459            .handle
25460            .dispatch(shared.prepare_command(ControlCommand::Register {
25461                instance_id: address.session.instance_id,
25462                agent_id: agent("claude"),
25463                transport: TransportKind::Pty,
25464            }))
25465            .unwrap();
25466        runtime.tick().await;
25467        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25468        let revision_before = shared.handle.snapshot().revision;
25469        let history_sequence_before = shared.current_sequence();
25470        let terminal_size = gate4agent_types::TerminalSize {
25471            rows: 24,
25472            columns: 80,
25473        };
25474
25475        for request in [
25476            NodeRequest::Prompt {
25477                session: address.clone(),
25478                text: "prompt".to_owned(),
25479            },
25480            NodeRequest::Paste {
25481                session: address.clone(),
25482                text: "paste".to_owned(),
25483            },
25484            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
25485            // the same semantic capabilities as `Prompt`/`Paste`. A bare
25486            // (promptless) resume is a raw PTY relaunch and IS admitted under
25487            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
25488            // in `provider_runtime.rs`; it is deliberately not exercised here.
25489            NodeRequest::Resume {
25490                session: address.clone(),
25491                terminal_size,
25492                initial_prompt: Some("resume".to_owned()),
25493            },
25494        ] {
25495            let error = process_request_inner(
25496                &shared,
25497                77,
25498                ClientRole::Operator,
25499                request,
25500            )
25501            .await
25502            .unwrap_err();
25503            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25504        }
25505
25506        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25507        assert_eq!(shared.handle.snapshot().revision, revision_before);
25508        assert_eq!(shared.current_sequence(), history_sequence_before);
25509        let bindings = shared
25510            .session_bindings
25511            .lock()
25512            .unwrap_or_else(|poisoned| poisoned.into_inner());
25513        let binding = bindings.get(&address.session.instance_id).unwrap();
25514        assert_eq!(binding.generation, address.session.generation);
25515        assert!(binding.pending_resume.is_none());
25516        assert!(binding.record_id.is_none());
25517    }
25518
25519    #[tokio::test]
25520    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25521        let catalog = active_registry().unwrap();
25522        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25523        let primary = WorkspaceConfig::new(
25524            WorkspaceId::new("primary").unwrap(),
25525            std::env::current_dir().unwrap(),
25526        )
25527        .unwrap();
25528        let shared = NodeShared::new(
25529            handle,
25530            "fixture-token".to_owned(),
25531            NodeId::new("node-1").unwrap(),
25532            vec![primary],
25533            vec![agent("claude")],
25534        );
25535        shared
25536            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25537            .unwrap();
25538        let before = shared.snapshot();
25539        let error = process_request_inner(
25540            &shared,
25541            77,
25542            ClientRole::Operator,
25543            NodeRequest::RegisterWorkspace {
25544                workspace_id: WorkspaceId::new("foreign").unwrap(),
25545                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25546            },
25547        )
25548        .await
25549        .unwrap_err();
25550        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25551        assert_eq!(error.message, "invalid-request");
25552        assert_eq!(shared.snapshot().workspaces, before.workspaces);
25553    }
25554
25555    #[tokio::test]
25556    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25557        let catalog = active_registry().unwrap();
25558        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25559        let primary_id = WorkspaceId::new("primary").unwrap();
25560        let primary = WorkspaceConfig::new(
25561            primary_id.clone(),
25562            std::env::current_dir().unwrap(),
25563        )
25564        .unwrap();
25565        let shared = NodeShared::new(
25566            handle,
25567            "fixture-token".to_owned(),
25568            NodeId::new("node-1").unwrap(),
25569            vec![primary.clone()],
25570            vec![agent("claude")],
25571        );
25572        let secondary_id = WorkspaceId::new("secondary").unwrap();
25573        let secondary = shared
25574            .register_workspace(
25575                secondary_id.clone(),
25576                std::env::temp_dir().to_string_lossy().into_owned(),
25577            )
25578            .await
25579            .unwrap();
25580        assert_eq!(secondary.workspace_id, secondary_id);
25581        assert!(secondary.sessions.is_empty());
25582        assert_eq!(shared.snapshot().workspaces.len(), 2);
25583
25584        let duplicate_id = shared
25585            .register_workspace(
25586                secondary_id.clone(),
25587                primary.canonical_root().to_owned(),
25588            )
25589            .await
25590            .unwrap_err();
25591        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25592        let duplicate_root = shared
25593            .register_workspace(
25594                WorkspaceId::new("duplicate-root").unwrap(),
25595                windows_path_text(&secondary.canonical_root).to_owned(),
25596            )
25597            .await
25598            .unwrap_err();
25599        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25600
25601        let address = SessionAddress {
25602            workspace_id: secondary_id.clone(),
25603            session: SessionKey {
25604                instance_id: AgentInstanceId(41),
25605                generation: SessionGeneration(1),
25606            },
25607        };
25608        shared.bind_session(
25609            address.session.instance_id,
25610            secondary_id.clone(),
25611            address.session.generation,
25612        );
25613        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25614        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25615        shared.remove_binding(&address);
25616        shared.unregister_workspace(&secondary_id).unwrap();
25617        assert_eq!(shared.snapshot().workspaces.len(), 1);
25618
25619        let last = shared.unregister_workspace(&primary_id).unwrap_err();
25620        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25621        let history = shared
25622            .history
25623            .lock()
25624            .unwrap_or_else(|poisoned| poisoned.into_inner());
25625        assert!(matches!(
25626            &history.events[0],
25627            NodeEventEnvelope {
25628                sequence: 1,
25629                event: NodeEvent::WorkspaceAdded { workspace },
25630            } if workspace.workspace_id == secondary_id
25631        ));
25632        assert!(matches!(
25633            &history.events[1],
25634            NodeEventEnvelope {
25635                sequence: 2,
25636                event: NodeEvent::WorkspaceRemoved { workspace_id },
25637            } if workspace_id == &secondary_id
25638        ));
25639    }
25640
25641    #[tokio::test]
25642    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25643        let secondary_root = temporary_workspace_root("provider-session-reference-index");
25644        std::fs::create_dir_all(&secondary_root).unwrap();
25645        let catalog = active_registry().unwrap();
25646        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25647        let primary_id = WorkspaceId::new("primary").unwrap();
25648        let secondary_id = WorkspaceId::new("secondary").unwrap();
25649        let shared = NodeShared::new(
25650            handle,
25651            "fixture-token".to_owned(),
25652            NodeId::new("node-session-reference-index").unwrap(),
25653            vec![
25654                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25655                    .unwrap(),
25656                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25657            ],
25658            vec![agent("claude"), agent("codex")],
25659        );
25660        let identity = ProviderSessionIdentity {
25661            key: gate4agent_types::ProviderSessionKey::SessionId,
25662            id: "provider-session-42".to_owned(),
25663            transcript_path: None,
25664        };
25665        let index_request = |workspace_id: WorkspaceId,
25666                             provider: AgentId,
25667                             identity: ProviderSessionIdentity,
25668                             display_name: &str| {
25669            NodeRequest::IndexProviderSession {
25670                workspace_id,
25671                provider,
25672                identity,
25673                display_name: display_name.to_owned(),
25674            }
25675        };
25676        let request = index_request(
25677            primary_id.clone(),
25678            agent("claude"),
25679            identity.clone(),
25680            "release shepherd",
25681        );
25682        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25683            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25684        }));
25685        let observer = process_request_inner(
25686            &shared,
25687            77,
25688            ClientRole::Observer,
25689            request.clone(),
25690        )
25691        .await
25692        .unwrap_err();
25693        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25694        let uncontrolled = process_request_inner(
25695            &shared,
25696            77,
25697            ClientRole::Operator,
25698            request.clone(),
25699        )
25700        .await
25701        .unwrap_err();
25702        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25703        shared
25704            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25705            .unwrap();
25706
25707        let indexed = process_request_inner(
25708            &shared,
25709            77,
25710            ClientRole::Operator,
25711            request.clone(),
25712        )
25713        .await
25714        .unwrap();
25715        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25716            panic!("provider session reference index returned another response");
25717        };
25718        assert_eq!(record.state, ManagedSessionState::Dormant);
25719        assert_eq!(record.mode, SessionMode::Pty);
25720        assert_eq!(record.workspace_id, primary_id);
25721        assert_eq!(record.provider_session.as_ref(), Some(&identity));
25722        assert!(record.active_session.is_none());
25723        assert!(record.environment_profile.is_none());
25724        assert!(record.bundle.is_none());
25725        assert!(record.context.is_none());
25726
25727        let repeated = process_request_inner(
25728            &shared,
25729            77,
25730            ClientRole::Operator,
25731            index_request(
25732                primary_id.clone(),
25733                agent("claude"),
25734                identity.clone(),
25735                "ignored replacement",
25736            ),
25737        )
25738        .await
25739        .unwrap();
25740        assert!(matches!(
25741            repeated,
25742            NodeResponse::ProviderSessionIndexed { record: ref existing }
25743                if existing.record_id == record.record_id
25744                    && existing.display_name == "release shepherd"
25745        ));
25746        let conflicting_workspace = process_request_inner(
25747            &shared,
25748            77,
25749            ClientRole::Operator,
25750            index_request(
25751                secondary_id,
25752                agent("claude"),
25753                identity.clone(),
25754                "release shepherd",
25755            ),
25756        )
25757        .await
25758        .unwrap_err();
25759        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25760        let conflicting_provider = process_request_inner(
25761            &shared,
25762            77,
25763            ClientRole::Operator,
25764            index_request(
25765                primary_id.clone(),
25766                agent("codex"),
25767                identity.clone(),
25768                "release shepherd",
25769            ),
25770        )
25771        .await
25772        .unwrap_err();
25773        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25774        let transcript_rejected = process_request_inner(
25775            &shared,
25776            77,
25777            ClientRole::Operator,
25778            index_request(
25779                primary_id,
25780                agent("claude"),
25781                ProviderSessionIdentity {
25782                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25783                    ..identity
25784                },
25785                "release shepherd",
25786            ),
25787        )
25788        .await
25789        .unwrap_err();
25790        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25791        let upserts = shared
25792            .history
25793            .lock()
25794            .unwrap_or_else(|poisoned| poisoned.into_inner())
25795            .events
25796            .iter()
25797            .filter(|event| matches!(
25798                &event.event,
25799                NodeEvent::SessionRecordUpserted { record: published }
25800                    if published.record_id == record.record_id
25801            ))
25802            .count();
25803        assert_eq!(upserts, 1);
25804        std::fs::remove_dir_all(secondary_root).unwrap();
25805    }
25806
25807    #[tokio::test]
25808    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25809        let fixture_root = temporary_workspace_root("standalone-independent");
25810        let target = fixture_root.join("independent");
25811        std::fs::create_dir_all(&fixture_root).unwrap();
25812        let catalog = active_registry().unwrap();
25813        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25814        let selected = WorkspaceConfig::new(
25815            WorkspaceId::new("primary").unwrap(),
25816            std::env::current_dir().unwrap(),
25817        )
25818        .unwrap();
25819        let selected_root = selected.canonical_root().to_owned();
25820        let shared = NodeShared::new(
25821            handle,
25822            "fixture-token".to_owned(),
25823            NodeId::new("node-standalone").unwrap(),
25824            vec![selected],
25825            vec![agent("claude")],
25826        );
25827        let workspace_id = WorkspaceId::new("independent").unwrap();
25828        let request = NodeRequest::CreateStandaloneWorkspace {
25829            workspace_id: workspace_id.clone(),
25830            root: opaque_windows_path(target.to_string_lossy().into_owned()),
25831            initial_branch: Some("main".to_owned()),
25832        };
25833        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25834            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25835        }));
25836        assert!(!request_uses_unnegotiated_capability(
25837            &request,
25838            &baseline_capabilities().unwrap(),
25839        ));
25840        assert!(request_uses_unnegotiated_capability(&request, &[]));
25841
25842        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25843            .await
25844            .unwrap_err();
25845        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25846        assert!(!target.exists());
25847        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25848            .await
25849            .unwrap_err();
25850        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25851        assert!(!target.exists());
25852        shared
25853            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25854            .unwrap();
25855
25856        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25857            .await
25858            .unwrap();
25859        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25860            panic!("standalone workspace creation returned another response");
25861        };
25862        assert_eq!(workspace.workspace_id, workspace_id);
25863        assert!(target.join(".git").is_dir());
25864        let branch = run_git_bounded(
25865            target.to_str().unwrap(),
25866            &["branch", "--show-current"],
25867            GIT_OUTPUT_MAX_BYTES,
25868        )
25869        .await
25870        .unwrap();
25871        assert!(branch.success);
25872        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25873        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25874        assert_eq!(worktrees.len(), 1);
25875        let listed_root = WorkspaceConfig::new(
25876            WorkspaceId::new("listed-independent").unwrap(),
25877            &worktrees[0].path,
25878        )
25879        .unwrap();
25880        assert!(platform::roots_equal(
25881            listed_root.canonical_root(),
25882            windows_path_text(&workspace.canonical_root),
25883        ));
25884        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25885        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25886            snapshot.workspace_id == workspace_id
25887                && snapshot.canonical_root == workspace.canonical_root
25888        }));
25889        assert!(shared
25890            .history
25891            .lock()
25892            .unwrap_or_else(|poisoned| poisoned.into_inner())
25893            .events
25894            .iter()
25895            .any(|event| matches!(
25896                &event.event,
25897                NodeEvent::WorkspaceAdded { workspace }
25898                    if workspace.workspace_id == workspace_id
25899            )));
25900        std::fs::remove_dir_all(fixture_root).unwrap();
25901    }
25902
25903    #[tokio::test]
25904    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25905        let fixture_root = temporary_workspace_root("standalone-compensation");
25906        let created_target = fixture_root.join("created-target");
25907        let existing_target = fixture_root.join("existing-target");
25908        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25909        let invalid_state_path = invalid_state_parent.join("state.json");
25910        std::fs::create_dir_all(&existing_target).unwrap();
25911        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25912        let catalog = active_registry().unwrap();
25913        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25914        let primary = WorkspaceConfig::new(
25915            WorkspaceId::new("primary").unwrap(),
25916            std::env::current_dir().unwrap(),
25917        )
25918        .unwrap();
25919        let mut shared = NodeShared::new(
25920            handle,
25921            "fixture-token".to_owned(),
25922            NodeId::new("node-standalone-compensation").unwrap(),
25923            vec![primary],
25924            vec![agent("claude")],
25925        );
25926        shared.state_path = Some(invalid_state_path);
25927
25928        let created_recovery = shared
25929            .create_standalone_workspace(
25930                WorkspaceId::new("compensated").unwrap(),
25931                created_target.to_string_lossy().into_owned(),
25932                Some("main".to_owned()),
25933            )
25934            .await
25935            .unwrap_err();
25936        assert_eq!(
25937            created_recovery.code,
25938            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25939        );
25940        assert!(created_target.join(".git").is_dir());
25941        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25942            workspace.workspace_id.as_str() != "compensated"
25943        }));
25944
25945        let existing_recovery = shared
25946            .create_standalone_workspace(
25947                WorkspaceId::new("restored").unwrap(),
25948                existing_target.to_string_lossy().into_owned(),
25949                None,
25950            )
25951            .await
25952            .unwrap_err();
25953        assert_eq!(
25954            existing_recovery.code,
25955            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25956        );
25957        assert!(existing_target.is_dir());
25958        assert!(existing_target.join(".git").is_dir());
25959        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25960            workspace.workspace_id.as_str() != "restored"
25961        }));
25962
25963        let registered_empty = fixture_root.join("registered-empty");
25964        std::fs::create_dir(&registered_empty).unwrap();
25965        let catalog = active_registry().unwrap();
25966        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25967        let registered = WorkspaceConfig::new(
25968            WorkspaceId::new("registered-empty").unwrap(),
25969            &registered_empty,
25970        )
25971        .unwrap();
25972        let duplicate_shared = NodeShared::new(
25973            handle,
25974            "fixture-token".to_owned(),
25975            NodeId::new("node-standalone-duplicate-root").unwrap(),
25976            vec![registered],
25977            vec![agent("claude")],
25978        );
25979        let duplicate = duplicate_shared
25980            .create_standalone_workspace(
25981                WorkspaceId::new("duplicate-root").unwrap(),
25982                registered_empty.to_string_lossy().into_owned(),
25983                None,
25984            )
25985            .await
25986            .unwrap_err();
25987        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
25988        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
25989        std::fs::remove_dir_all(fixture_root).unwrap();
25990    }
25991
25992    #[test]
25993    fn managed_record_id_collision_preserves_the_existing_record() {
25994        let catalog = active_registry().unwrap();
25995        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25996        let workspace = WorkspaceConfig::new(
25997            WorkspaceId::new("primary").unwrap(),
25998            std::env::current_dir().unwrap(),
25999        )
26000        .unwrap();
26001        let shared = NodeShared::new(
26002            handle,
26003            "fixture-token".to_owned(),
26004            NodeId::new("node-1").unwrap(),
26005            vec![workspace.clone()],
26006            vec![agent("claude")],
26007        );
26008        let record_id = SessionRecordId::new("sr-collision").unwrap();
26009        let original = ManagedSessionRecord {
26010            record_id: record_id.clone(),
26011            display_name: "original".to_owned(),
26012            provider: agent("claude"),
26013            mode: SessionMode::Pty,
26014            state: ManagedSessionState::Dormant,
26015            workspace_id: workspace.workspace_id,
26016            canonical_root: opaque_windows_path(workspace.canonical_root),
26017            provider_session: None,
26018            active_session: None,
26019            environment_profile: None,
26020            bundle: None,
26021            context_id: None,
26022            context: None,
26023            exported_context: None,
26024            task_binding: None,
26025            created_at_unix_ms: 1,
26026            updated_at_unix_ms: 1,
26027            last_error: None,
26028        };
26029        shared.insert_record(original.clone()).unwrap();
26030        let mut collision = original.clone();
26031        collision.display_name = "replacement".to_owned();
26032        let error = shared.insert_record(collision).unwrap_err();
26033        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26034        assert_eq!(shared.record(&record_id).unwrap(), original);
26035    }
26036
26037    #[test]
26038    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26039        let catalog = active_registry().unwrap();
26040        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26041        let workspace = WorkspaceConfig::new(
26042            WorkspaceId::new("primary").unwrap(),
26043            std::env::current_dir().unwrap(),
26044        )
26045        .unwrap();
26046        let shared = NodeShared::new(
26047            handle,
26048            "fixture-token".to_owned(),
26049            NodeId::new("node-1").unwrap(),
26050            vec![workspace.clone()],
26051            vec![agent("claude")],
26052        );
26053        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26054        shared
26055            .insert_record(ManagedSessionRecord {
26056                record_id: record_id.clone(),
26057                display_name: "runtime error".to_owned(),
26058                provider: agent("claude"),
26059                mode: SessionMode::Pty,
26060                state: ManagedSessionState::IdentityPending,
26061                workspace_id: workspace.workspace_id,
26062                canonical_root: opaque_windows_path(workspace.canonical_root),
26063                provider_session: None,
26064                active_session: None,
26065                environment_profile: None,
26066                bundle: None,
26067                context_id: None,
26068                context: None,
26069                exported_context: None,
26070                task_binding: None,
26071                created_at_unix_ms: 1,
26072                updated_at_unix_ms: 1,
26073                last_error: None,
26074            })
26075            .unwrap();
26076
26077        shared
26078            .mark_record_error(
26079                &record_id,
26080                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26081            )
26082            .unwrap();
26083        let record = shared.record(&record_id).unwrap();
26084        assert_eq!(record.state, ManagedSessionState::Unavailable);
26085        assert_eq!(
26086            record.last_error.as_deref(),
26087            Some("provider-runtime-failed"),
26088        );
26089        let snapshot = shared.snapshot();
26090        assert_eq!(snapshot.session_records, vec![record]);
26091    }
26092
26093    /// Regression coverage for the "operator sees `deadline` instead of the
26094    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
26095    /// able to learn a command's asynchronous kernel rejection (here,
26096    /// specifically classified as `UnsupportedTransport`) exactly once, and
26097    /// must not still find it after that -- proving the fast path this
26098    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
26099    /// loop) drains the reason instead of leaving it to be rediscovered (or
26100    /// missed) on a later poll.
26101    #[test]
26102    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26103        let catalog = active_registry().unwrap();
26104        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26105        let workspace = WorkspaceConfig::new(
26106            WorkspaceId::new("primary").unwrap(),
26107            std::env::current_dir().unwrap(),
26108        )
26109        .unwrap();
26110        let shared = NodeShared::new(
26111            handle,
26112            "fixture-token".to_owned(),
26113            NodeId::new("node-1").unwrap(),
26114            vec![workspace],
26115            vec![agent("claude")],
26116        );
26117
26118        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26119
26120        shared.record_rejected_command(
26121            CommandId(1),
26122            "agent 'claude' does not support transport Acp".to_owned(),
26123            true,
26124        );
26125        // An unrelated command_id must not see another command's reason.
26126        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26127
26128        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26129        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26130        assert!(reason.unsupported_transport);
26131
26132        // Consumed: a second read for the same command_id finds nothing.
26133        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26134
26135        // A rejection unrelated to transport support keeps its own
26136        // classification distinct, so the reader can choose the generic
26137        // `BackendOperationFailed` code instead of misreporting
26138        // `UnsupportedTransport`.
26139        shared.record_rejected_command(
26140            CommandId(3),
26141            "backend busy".to_owned(),
26142            false,
26143        );
26144        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26145        assert!(!unclassified.unsupported_transport);
26146    }
26147
26148    #[test]
26149    fn provider_identity_update_is_ignored_without_exact_session_policy() {
26150        let catalog = active_registry().unwrap();
26151        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26152        let workspace = WorkspaceConfig::new(
26153            WorkspaceId::new("primary").unwrap(),
26154            std::env::current_dir().unwrap(),
26155        )
26156        .unwrap();
26157        let shared = NodeShared::new(
26158            handle,
26159            "fixture-token".to_owned(),
26160            NodeId::new("node-identity-policy").unwrap(),
26161            vec![workspace.clone()],
26162            vec![agent("claude")],
26163        );
26164        let address = SessionAddress {
26165            workspace_id: workspace.workspace_id.clone(),
26166            session: SessionKey {
26167                instance_id: AgentInstanceId(90),
26168                generation: SessionGeneration(1),
26169            },
26170        };
26171        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26172        shared
26173            .insert_record(ManagedSessionRecord {
26174                record_id: record_id.clone(),
26175                display_name: "policy denied".to_owned(),
26176                provider: agent("claude"),
26177                mode: SessionMode::Pty,
26178                state: ManagedSessionState::IdentityPending,
26179                workspace_id: workspace.workspace_id,
26180                canonical_root: opaque_windows_path(workspace.canonical_root),
26181                provider_session: None,
26182                active_session: Some(address.clone()),
26183                environment_profile: None,
26184                bundle: None,
26185                context_id: None,
26186                context: None,
26187                exported_context: None,
26188                task_binding: None,
26189                created_at_unix_ms: 1,
26190                updated_at_unix_ms: 1,
26191                last_error: None,
26192            })
26193            .unwrap();
26194        let event = ControlEvent {
26195            sequence: 1,
26196            command_id: None,
26197            instance_id: address.session.instance_id,
26198            generation: address.session.generation,
26199            event: ControlEventKind::ProviderEvent {
26200                sequence: 1,
26201                source: gate4agent_types::ProviderSource {
26202                    family: AdapterFamily::PtySemantic,
26203                    binding: AdapterBinding::new(
26204                        gate4agent_types::AdapterId::new("claude").unwrap(),
26205                        "fixture/v1",
26206                        gate4agent_types::AdapterVerification::SyntheticFixture,
26207                    )
26208                    .unwrap(),
26209                },
26210                source_sequence: 1,
26211                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26212                    identity: gate4agent_types::ProviderSessionIdentity {
26213                        key: gate4agent_types::ProviderSessionKey::SessionId,
26214                        id: "provider-session".to_owned(),
26215                        transcript_path: None,
26216                    },
26217                },
26218            },
26219        };
26220
26221        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26222
26223        let record = shared.record(&record_id).unwrap();
26224        assert!(record.provider_session.is_none());
26225        assert_eq!(record.state, ManagedSessionState::IdentityPending);
26226        assert_eq!(record.active_session.as_ref(), Some(&address));
26227    }
26228
26229    #[test]
26230    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26231        let secondary_root = temporary_workspace_root("identity-scope");
26232        std::fs::create_dir_all(&secondary_root).unwrap();
26233        let catalog = active_registry().unwrap();
26234        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26235        let primary = WorkspaceConfig::new(
26236            WorkspaceId::new("primary").unwrap(),
26237            std::env::current_dir().unwrap(),
26238        )
26239        .unwrap();
26240        let secondary = WorkspaceConfig::new(
26241            WorkspaceId::new("secondary").unwrap(),
26242            &secondary_root,
26243        )
26244        .unwrap();
26245        let shared = NodeShared::new(
26246            handle,
26247            "fixture-token".to_owned(),
26248            NodeId::new("node-1").unwrap(),
26249            vec![primary.clone(), secondary.clone()],
26250            vec![agent("claude")],
26251        );
26252        let identity = gate4agent_types::ProviderSessionIdentity {
26253            key: gate4agent_types::ProviderSessionKey::SessionId,
26254            id: "provider-session".to_owned(),
26255            transcript_path: None,
26256        };
26257        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26258        shared
26259            .insert_record(ManagedSessionRecord {
26260                record_id: dormant_id.clone(),
26261                display_name: "dormant".to_owned(),
26262                provider: agent("claude"),
26263                mode: SessionMode::Pty,
26264                state: ManagedSessionState::Dormant,
26265                workspace_id: primary.workspace_id,
26266                canonical_root: opaque_windows_path(primary.canonical_root),
26267                provider_session: Some(identity.clone()),
26268                active_session: None,
26269                environment_profile: None,
26270                bundle: None,
26271                context_id: None,
26272                context: None,
26273                exported_context: None,
26274                task_binding: None,
26275                created_at_unix_ms: 1,
26276                updated_at_unix_ms: 1,
26277                last_error: None,
26278            })
26279            .unwrap();
26280        let address = SessionAddress {
26281            workspace_id: secondary.workspace_id.clone(),
26282            session: SessionKey {
26283                instance_id: AgentInstanceId(91),
26284                generation: SessionGeneration(1),
26285            },
26286        };
26287        let current_id = SessionRecordId::new("sr-current").unwrap();
26288        shared
26289            .insert_record(ManagedSessionRecord {
26290                record_id: current_id.clone(),
26291                display_name: "current".to_owned(),
26292                provider: agent("claude"),
26293                mode: SessionMode::Pty,
26294                state: ManagedSessionState::IdentityPending,
26295                workspace_id: secondary.workspace_id,
26296                canonical_root: opaque_windows_path(secondary.canonical_root),
26297                provider_session: None,
26298                active_session: Some(address.clone()),
26299                environment_profile: None,
26300                bundle: None,
26301                context_id: None,
26302                context: None,
26303                exported_context: None,
26304                task_binding: None,
26305                created_at_unix_ms: 2,
26306                updated_at_unix_ms: 2,
26307                last_error: None,
26308            })
26309            .unwrap();
26310        shared.bind_managed_session(
26311            &address,
26312            current_id.clone(),
26313            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26314            None,
26315        );
26316        let event = ControlEvent {
26317            sequence: 1,
26318            command_id: None,
26319            instance_id: address.session.instance_id,
26320            generation: address.session.generation,
26321            event: ControlEventKind::ProviderEvent {
26322                sequence: 1,
26323                source: gate4agent_types::ProviderSource {
26324                    family: gate4agent_types::AdapterFamily::PtySemantic,
26325                    binding: gate4agent_types::AdapterBinding::new(
26326                        gate4agent_types::AdapterId::new("claude").unwrap(),
26327                        "fixture/v1",
26328                        gate4agent_types::AdapterVerification::SyntheticFixture,
26329                    )
26330                    .unwrap(),
26331                },
26332                source_sequence: 1,
26333                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26334                    identity,
26335                },
26336            },
26337        };
26338        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
26339
26340        let binding = shared
26341            .session_bindings
26342            .lock()
26343            .unwrap_or_else(|poisoned| poisoned.into_inner())
26344            .get(&address.session.instance_id)
26345            .cloned()
26346            .unwrap();
26347        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
26348        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26349        let current = shared.record(&current_id).unwrap();
26350        assert_eq!(current.state, ManagedSessionState::Unavailable);
26351        assert_eq!(current.active_session.as_ref(), Some(&address));
26352        assert_eq!(
26353            current.last_error.as_deref(),
26354            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26355        );
26356        std::fs::remove_dir_all(secondary_root).unwrap();
26357    }
26358
26359    #[tokio::test]
26360    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26361        let root = temporary_workspace_root("semantic-provider-session");
26362        std::fs::create_dir_all(&root).unwrap();
26363        let state_path = root.join("state-v1.json");
26364        let catalog = active_registry().unwrap();
26365        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26366        let workspace = WorkspaceConfig::new(
26367            WorkspaceId::new("primary").unwrap(),
26368            &root,
26369        )
26370        .unwrap();
26371        let node_id = NodeId::new("node-semantic-identity").unwrap();
26372        let shared = NodeShared::new_with_incarnation(
26373            handle,
26374            "fixture-token".to_owned(),
26375            node_id.clone(),
26376            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26377            vec![workspace.clone()],
26378            vec![agent("claude")],
26379            ProviderRuntimeStatuses::default(),
26380            None,
26381            Vec::new(),
26382            Vec::new(),
26383            SpawnProfileRegistry::default(),
26384            None,
26385            None,
26386            None,
26387            Some(state_path.clone()),
26388            Vec::new(),
26389            Vec::new(),
26390            Vec::new(),
26391            Vec::new(),
26392            None,
26393            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26394        );
26395        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26396            key: gate4agent_types::ProviderSessionKey::SessionId,
26397            id: "provider-session".to_owned(),
26398            transcript_path: None,
26399        };
26400        let durable_id = SessionRecordId::new("sr-durable").unwrap();
26401        shared
26402            .insert_record(ManagedSessionRecord {
26403                record_id: durable_id.clone(),
26404                display_name: "durable".to_owned(),
26405                provider: agent("claude"),
26406                mode: SessionMode::Pty,
26407                state: ManagedSessionState::Dormant,
26408                workspace_id: workspace.workspace_id.clone(),
26409                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26410                provider_session: Some(persisted_identity.clone()),
26411                active_session: None,
26412                environment_profile: None,
26413                bundle: None,
26414                context_id: None,
26415                context: None,
26416                exported_context: None,
26417                task_binding: None,
26418                created_at_unix_ms: 1,
26419                updated_at_unix_ms: 1,
26420                last_error: None,
26421            })
26422            .unwrap();
26423        let address = SessionAddress {
26424            workspace_id: workspace.workspace_id.clone(),
26425            session: SessionKey {
26426                instance_id: AgentInstanceId(92),
26427                generation: SessionGeneration(2),
26428            },
26429        };
26430        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26431        shared
26432            .insert_record(ManagedSessionRecord {
26433                record_id: pending_id.clone(),
26434                display_name: "pending".to_owned(),
26435                provider: agent("claude"),
26436                mode: SessionMode::Pty,
26437                state: ManagedSessionState::IdentityPending,
26438                workspace_id: workspace.workspace_id.clone(),
26439                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26440                provider_session: None,
26441                active_session: Some(address.clone()),
26442                environment_profile: None,
26443                bundle: None,
26444                context_id: None,
26445                context: None,
26446                exported_context: None,
26447                task_binding: None,
26448                created_at_unix_ms: 2,
26449                updated_at_unix_ms: 2,
26450                last_error: None,
26451            })
26452            .unwrap();
26453        shared.bind_managed_session(
26454            &address,
26455            pending_id.clone(),
26456            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26457            None,
26458        );
26459        let observed_identity = gate4agent_types::ProviderSessionIdentity {
26460            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26461            ..persisted_identity
26462        };
26463        let event = ControlEvent {
26464            sequence: 1,
26465            command_id: None,
26466            instance_id: address.session.instance_id,
26467            generation: address.session.generation,
26468            event: ControlEventKind::ProviderEvent {
26469                sequence: 1,
26470                source: gate4agent_types::ProviderSource {
26471                    family: gate4agent_types::AdapterFamily::PtySemantic,
26472                    binding: gate4agent_types::AdapterBinding::new(
26473                        gate4agent_types::AdapterId::new("claude").unwrap(),
26474                        "fixture/v1",
26475                        gate4agent_types::AdapterVerification::SyntheticFixture,
26476                    )
26477                    .unwrap(),
26478                },
26479                source_sequence: 1,
26480                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26481                    identity: observed_identity.clone(),
26482                },
26483            },
26484        };
26485        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26486
26487        let snapshot = shared.snapshot();
26488        assert_eq!(snapshot.session_records.len(), 1);
26489        let live = &snapshot.session_records[0];
26490        assert_eq!(live.record_id, durable_id);
26491        assert_eq!(live.state, ManagedSessionState::Live);
26492        assert_eq!(live.active_session.as_ref(), Some(&address));
26493        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26494        let binding = shared
26495            .session_bindings
26496            .lock()
26497            .unwrap_or_else(|poisoned| poisoned.into_inner())
26498            .get(&address.session.instance_id)
26499            .cloned()
26500            .unwrap();
26501        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26502
26503        let mut duplicate = live.clone();
26504        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26505        duplicate.display_name = "duplicate".to_owned();
26506        duplicate.state = ManagedSessionState::Dormant;
26507        duplicate.active_session = None;
26508        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26509        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26510        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26511        assert_eq!(shared.snapshot().session_records.len(), 1);
26512
26513        let terminal_size = gate4agent_types::TerminalSize {
26514            rows: 24,
26515            columns: 80,
26516        };
26517        let (first_busy, second_busy) = tokio::join!(
26518            shared.resume_session_record(&durable_id, terminal_size, None),
26519            shared.resume_session_record(&durable_id, terminal_size, None),
26520        );
26521        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26522            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26523            assert_eq!(busy.message, "session-record-busy");
26524        }
26525
26526        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26527        assert_eq!(loaded.records.len(), 1);
26528        assert_eq!(loaded.records[0].record_id, durable_id);
26529        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26530        assert_eq!(
26531            loaded.records[0]
26532                .provider_session
26533                .as_ref()
26534                .unwrap()
26535                .transcript_path,
26536            None,
26537        );
26538        let catalog = active_registry().unwrap();
26539        let (reloaded_handle, _reloaded_runtime) =
26540            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26541        let reloaded = NodeShared::new_with_incarnation(
26542            reloaded_handle,
26543            "fixture-token".to_owned(),
26544            node_id,
26545            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26546            vec![workspace],
26547            vec![agent("claude")],
26548            ProviderRuntimeStatuses::default(),
26549            None,
26550            Vec::new(),
26551            Vec::new(),
26552            SpawnProfileRegistry::default(),
26553            None,
26554            None,
26555            None,
26556            None,
26557            loaded.records,
26558            loaded.managed_worktrees,
26559            loaded.managed_worktree_tombstones,
26560            loaded.materializations,
26561            loaded.warning,
26562            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26563        );
26564        assert_eq!(reloaded.snapshot().session_records.len(), 1);
26565        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26566        std::fs::remove_dir_all(root).unwrap();
26567    }
26568
26569    #[tokio::test]
26570    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26571        let secondary_root = temporary_workspace_root("workspace-restore");
26572        let other_root = temporary_workspace_root("workspace-conflict");
26573        std::fs::create_dir_all(&secondary_root).unwrap();
26574        std::fs::create_dir_all(&other_root).unwrap();
26575        let catalog = active_registry().unwrap();
26576        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26577        let primary = WorkspaceConfig::new(
26578            WorkspaceId::new("primary").unwrap(),
26579            std::env::current_dir().unwrap(),
26580        )
26581        .unwrap();
26582        let secondary = WorkspaceConfig::new(
26583            WorkspaceId::new("secondary").unwrap(),
26584            &secondary_root,
26585        )
26586        .unwrap();
26587        let shared = NodeShared::new(
26588            handle,
26589            "fixture-token".to_owned(),
26590            NodeId::new("node-1").unwrap(),
26591            vec![primary, secondary.clone()],
26592            vec![agent("claude")],
26593        );
26594        let record_id = SessionRecordId::new("sr-workspace").unwrap();
26595        shared
26596            .insert_record(ManagedSessionRecord {
26597                record_id: record_id.clone(),
26598                display_name: "workspace record".to_owned(),
26599                provider: agent("claude"),
26600                mode: SessionMode::Pty,
26601                state: ManagedSessionState::Dormant,
26602                workspace_id: secondary.workspace_id.clone(),
26603                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26604                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26605                    key: gate4agent_types::ProviderSessionKey::SessionId,
26606                    id: "provider-session".to_owned(),
26607                    transcript_path: None,
26608                }),
26609                active_session: None,
26610                environment_profile: None,
26611                bundle: None,
26612                context_id: None,
26613                context: None,
26614                exported_context: None,
26615                task_binding: None,
26616                created_at_unix_ms: 1,
26617                updated_at_unix_ms: 1,
26618                last_error: None,
26619            })
26620            .unwrap();
26621        shared.unregister_workspace(&secondary.workspace_id).unwrap();
26622        let unavailable = shared.record(&record_id).unwrap();
26623        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26624        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26625
26626        let conflict = shared
26627            .register_workspace(
26628                secondary.workspace_id.clone(),
26629                other_root.to_string_lossy().into_owned(),
26630            )
26631            .await
26632            .unwrap_err();
26633        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26634        shared
26635            .register_workspace(
26636                secondary.workspace_id,
26637                secondary_root.to_string_lossy().into_owned(),
26638            )
26639            .await
26640            .unwrap();
26641        let restored = shared.record(&record_id).unwrap();
26642        assert_eq!(restored.state, ManagedSessionState::Dormant);
26643        assert!(restored.last_error.is_none());
26644        std::fs::remove_dir_all(secondary_root).unwrap();
26645        std::fs::remove_dir_all(other_root).unwrap();
26646    }
26647
26648    #[test]
26649    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26650        let error = validate_workspace_request_root(
26651            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26652        )
26653        .unwrap_err();
26654        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26655        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26656        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26657    }
26658
26659    #[test]
26660    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26661        let catalog = active_registry().unwrap();
26662        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26663        let workspace = WorkspaceConfig::new(
26664            WorkspaceId::new("primary").unwrap(),
26665            std::env::current_dir().unwrap(),
26666        )
26667        .unwrap();
26668        let shared = NodeShared::new(
26669            handle,
26670            "fixture-token".to_owned(),
26671            NodeId::new("node-1").unwrap(),
26672            vec![workspace],
26673            vec![agent("claude")],
26674        );
26675        let publishers = 4_u64;
26676        let events_per_publisher = 256_u64;
26677        let start = std::sync::Barrier::new(publishers as usize + 1);
26678        let finished = AtomicU64::new(0);
26679
26680        std::thread::scope(|scope| {
26681            for _ in 0..publishers {
26682                scope.spawn(|| {
26683                    start.wait();
26684                    for _ in 0..events_per_publisher {
26685                        shared.publish(NodeEvent::ControllerChanged { controller: None });
26686                    }
26687                    finished.fetch_add(1, Ordering::Release);
26688                });
26689            }
26690            start.wait();
26691            while finished.load(Ordering::Acquire) != publishers {
26692                assert_resync_history_is_linearized(shared.resync(0));
26693                std::thread::yield_now();
26694            }
26695        });
26696        let response = shared.resync(0);
26697        assert_resync_history_is_linearized(response.clone());
26698        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26699            unreachable!("resync helper returned another response");
26700        };
26701        assert_eq!(event_sequence, publishers * events_per_publisher);
26702        assert_eq!(events.len() as u64, event_sequence);
26703    }
26704
26705    fn assert_resync_history_is_linearized(response: NodeResponse) {
26706        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26707            unreachable!("resync helper returned another response");
26708        };
26709        assert!(events.windows(2).all(|pair| {
26710            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26711        }));
26712        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26713    }
26714
26715    #[test]
26716    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26717        assert_eq!(
26718            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26719            r"C:\repo\worktree",
26720        );
26721        assert_eq!(
26722            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26723            r"\\server\share\repo",
26724        );
26725        assert!(validate_workspace_root(
26726            &WorkspaceId::new("network").unwrap(),
26727            r"\\server\share\repo",
26728        )
26729        .is_err());
26730        let workspace = WorkspaceConfig::new(
26731            WorkspaceId::new("primary").unwrap(),
26732            std::env::current_dir().unwrap(),
26733        )
26734        .unwrap();
26735        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26736    }
26737
26738    #[test]
26739    fn resume_rebinds_only_on_the_expected_authorized_generation() {
26740        let catalog = active_registry().unwrap();
26741        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26742        let workspace_id = WorkspaceId::new("primary").unwrap();
26743        let workspace = WorkspaceConfig::new(
26744            workspace_id.clone(),
26745            std::env::current_dir().unwrap(),
26746        )
26747        .unwrap();
26748        let shared = NodeShared::new(
26749            handle,
26750            "fixture-token".to_owned(),
26751            NodeId::new("node-1").unwrap(),
26752            vec![workspace],
26753            vec![agent("claude")],
26754        );
26755        let instance_id = AgentInstanceId(17);
26756        let original = SessionAddress {
26757            workspace_id,
26758            session: SessionKey {
26759                instance_id,
26760                generation: SessionGeneration(3),
26761            },
26762        };
26763        shared.bind_session(
26764            instance_id,
26765            original.workspace_id.clone(),
26766            original.session.generation,
26767        );
26768        shared
26769            .arm_resume(
26770                &original,
26771                CommandId(41),
26772                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26773            )
26774            .unwrap();
26775
26776        shared.publish_control(ControlEvent {
26777            sequence: 1,
26778            command_id: None,
26779            instance_id,
26780            generation: SessionGeneration(4),
26781            event: ControlEventKind::Running { process_id: None },
26782        });
26783        assert_eq!(
26784            shared.address_for(instance_id, SessionGeneration(3)),
26785            Some(original.clone()),
26786        );
26787        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26788
26789        shared.publish_control(ControlEvent {
26790            sequence: 2,
26791            command_id: Some(CommandId(42)),
26792            instance_id,
26793            generation: SessionGeneration(3),
26794            event: ControlEventKind::CommandRejected {
26795                message: "unrelated command".to_owned(),
26796            },
26797        });
26798
26799        shared.publish_control(ControlEvent {
26800            sequence: 3,
26801            command_id: None,
26802            instance_id,
26803            generation: SessionGeneration(4),
26804            event: ControlEventKind::ResumeAuthorized {
26805                session: gate4agent_types::ResumeSessionSummary {
26806                    key: gate4agent_types::ProviderSessionKey::SessionId,
26807                    id: "fixture-session".to_owned(),
26808                },
26809            },
26810        });
26811        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26812        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26813        assert_eq!(rebound.workspace_id, original.workspace_id);
26814        let history = shared
26815            .history
26816            .lock()
26817            .unwrap_or_else(|poisoned| poisoned.into_inner());
26818        assert!(history.events.iter().any(|envelope| matches!(
26819            &envelope.event,
26820            NodeEvent::Control { address, event }
26821                if address == &rebound
26822                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26823        )));
26824    }
26825
26826    #[test]
26827    fn all_node_pty_prompts_use_bracketed_paste_framing() {
26828        for agent in ["claude", "codex", "kimi"] {
26829            assert_eq!(
26830                prompt_framing(&AgentId::new(agent).unwrap()),
26831                PromptFraming::BracketedPaste
26832            );
26833        }
26834    }
26835
26836    fn generous_walk_deadline() -> Instant {
26837        Instant::now() + Duration::from_secs(30)
26838    }
26839
26840    #[test]
26841    fn workspace_tree_is_relative_and_skips_heavy_directories() {
26842        let root = temporary_workspace_root("tree");
26843        std::fs::create_dir_all(root.join("src")).unwrap();
26844        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26845        std::fs::create_dir_all(root.join("target/debug")).unwrap();
26846        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26847        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26848        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26849        // A renamed/agent-scoped build directory (`target-a2`-style, per the
26850        // real repository's own build layout) that carries the Cache
26851        // Directory Tagging Specification marker cargo writes into every
26852        // `--target-dir` it creates: must be skipped even though its name
26853        // does not exactly match `target`.
26854        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26855        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26856        // A directory that merely *starts with* `target` but carries no
26857        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
26858        // on the name alone, so this stays in the response.
26859        std::fs::create_dir_all(root.join("target-notes")).unwrap();
26860        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26861
26862        let (entries, truncated, _budget) =
26863            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26864        assert!(!truncated);
26865        assert!(entries.iter().any(|entry| {
26866            entry.relative_path.as_utf8() == Some("src")
26867                && entry.kind == WorkspaceEntryKind::Directory
26868        }));
26869        assert!(entries.iter().any(|entry| {
26870            entry.relative_path.as_utf8() == Some("src/lib.rs")
26871                && entry.kind == WorkspaceEntryKind::File
26872        }));
26873        assert!(entries.iter().any(|entry| {
26874            entry.relative_path.as_utf8() == Some("target-notes")
26875                && entry.kind == WorkspaceEntryKind::Directory
26876        }));
26877        assert!(entries.iter().any(|entry| {
26878            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26879                && entry.kind == WorkspaceEntryKind::File
26880        }));
26881        assert!(entries.iter().all(|entry| {
26882            let path = entry.relative_path.as_utf8().unwrap();
26883            !path.starts_with(".git")
26884                && !(path == "target" || path.starts_with("target/"))
26885                && !(path == "target-a2" || path.starts_with("target-a2/"))
26886                && !path.starts_with("node_modules")
26887        }));
26888
26889        std::fs::remove_dir_all(&root).unwrap();
26890    }
26891
26892    #[test]
26893    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26894        let root = temporary_workspace_root("tree-entry-cap");
26895        std::fs::create_dir_all(&root).unwrap();
26896        for index in 0..20 {
26897            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26898        }
26899
26900        let (entries, truncated, budget) =
26901            collect_workspace_entries(&root, generous_walk_deadline(), 5);
26902        assert!(truncated);
26903        assert!(budget.entry_cap_exceeded);
26904        assert!(!budget.time_budget_exceeded);
26905        assert!(entries.len() <= 5);
26906
26907        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26908            .expect("an entry-cap-truncated walk must report a truncation marker");
26909        assert!(inspection_truncation.walk_entry_cap_exceeded);
26910        assert!(!inspection_truncation.walk_time_budget_exceeded);
26911        assert!(!inspection_truncation.git_time_budget_exceeded);
26912        assert!(inspection_truncation.entries_visited >= 5);
26913
26914        std::fs::remove_dir_all(&root).unwrap();
26915    }
26916
26917    #[test]
26918    fn workspace_walk_within_budget_reports_no_truncation() {
26919        let root = temporary_workspace_root("tree-no-truncation");
26920        std::fs::create_dir_all(&root).unwrap();
26921        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26922
26923        let (_entries, truncated, budget) =
26924            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26925        assert!(!truncated);
26926        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26927
26928        std::fs::remove_dir_all(&root).unwrap();
26929    }
26930
26931    #[test]
26932    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26933        let root = temporary_workspace_root("tree-prefix-siblings");
26934        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26935        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26936        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26937        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26938
26939        let (entries, truncated, _budget) =
26940            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26941        assert!(!truncated);
26942        let paths = entries
26943            .iter()
26944            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26945            .collect::<Vec<_>>();
26946        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26947        assert_eq!(
26948            paths,
26949            vec![
26950                "gate4agent-c2".to_owned(),
26951                "gate4agent-c2-client".to_owned(),
26952                "gate4agent-c2-client/lib.rs".to_owned(),
26953                "gate4agent-c2/src".to_owned(),
26954                "gate4agent-c2/src/lib.rs".to_owned(),
26955            ],
26956        );
26957
26958        std::fs::remove_dir_all(&root).unwrap();
26959    }
26960
26961    #[test]
26962    fn git_parsers_cap_status_and_commit_results() {
26963        let mut status_output = Vec::new();
26964        for index in 0..=GIT_STATUS_MAX_ENTRIES {
26965            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26966        }
26967        let mut snapshot = git_snapshot_for_parser();
26968        parse_git_status(&status_output, &mut snapshot);
26969        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26970        assert!(snapshot.truncated);
26971        assert_eq!(snapshot.status[0].index_status, " ");
26972        assert_eq!(snapshot.status[0].worktree_status, "M");
26973
26974        snapshot.truncated = false;
26975        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26976            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
26977            .collect::<String>();
26978        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
26979        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
26980        assert!(snapshot.truncated);
26981    }
26982
26983    #[test]
26984    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
26985        let mut snapshot = git_snapshot_for_parser();
26986        parse_git_status(
26987            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
26988            &mut snapshot,
26989        );
26990
26991        assert!(!snapshot.truncated);
26992        assert_eq!(snapshot.status.len(), 3);
26993        assert_eq!(snapshot.status[0].index_status, "R");
26994        assert_eq!(snapshot.status[0].worktree_status, " ");
26995        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
26996        assert_eq!(
26997            snapshot.status[0]
26998                .previous_path
26999                .as_ref()
27000                .and_then(RepositoryPath::as_utf8),
27001            Some("src/previous name.rs"),
27002        );
27003        assert_eq!(snapshot.status[1].index_status, " ");
27004        assert_eq!(snapshot.status[1].worktree_status, "C");
27005        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27006        assert_eq!(
27007            snapshot.status[1]
27008                .previous_path
27009                .as_ref()
27010                .and_then(RepositoryPath::as_utf8),
27011            Some("src/original.rs"),
27012        );
27013        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27014        assert!(snapshot.status[2].previous_path.is_none());
27015    }
27016
27017    #[test]
27018    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27019        let mut snapshot = git_snapshot_for_parser();
27020        parse_git_status(
27021            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
27022            &mut snapshot,
27023        );
27024
27025        assert!(snapshot.truncated);
27026        assert_eq!(snapshot.status.len(), 1);
27027        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27028        assert!(snapshot.status[0].previous_path.is_none());
27029    }
27030
27031    #[tokio::test]
27032    async fn observer_can_browse_host_directories_without_controller() {
27033        let root = temporary_workspace_root("host-directory-observer");
27034        std::fs::create_dir_all(root.join("directory-b")).unwrap();
27035        std::fs::create_dir_all(root.join("directory-a")).unwrap();
27036        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27037        let workspace_id = WorkspaceId::new("primary").unwrap();
27038        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27039        let catalog = active_registry().unwrap();
27040        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27041        let shared = NodeShared::new(
27042            handle,
27043            "fixture-token".to_owned(),
27044            NodeId::new("node-1").unwrap(),
27045            vec![workspace],
27046            vec![agent("claude")],
27047        );
27048        let request = NodeRequest::BrowseHostDirectories {
27049            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27050            after: None,
27051        };
27052        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27053            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27054        }));
27055        assert!(!request_uses_unnegotiated_capability(
27056            &request,
27057            &baseline_capabilities().unwrap(),
27058        ));
27059        assert!(request_uses_unnegotiated_capability(&request, &[]));
27060
27061        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27062            .await
27063            .unwrap();
27064        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27065            panic!("host directory browse returned another response");
27066        };
27067        assert_eq!(
27068            listing
27069                .entries
27070                .iter()
27071                .map(|entry| entry.display_name.as_str())
27072                .collect::<Vec<_>>(),
27073            vec!["directory-a", "directory-b"],
27074        );
27075        assert!(listing.entries.iter().all(|entry| !entry.is_link));
27076        assert!(!listing.incomplete);
27077        std::fs::remove_dir_all(root).unwrap();
27078    }
27079
27080    #[tokio::test]
27081    async fn observer_can_inspect_only_a_registered_workspace() {
27082        let root = temporary_workspace_root("observer");
27083        std::fs::create_dir_all(root.join("src")).unwrap();
27084        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27085        let workspace_id = WorkspaceId::new("primary").unwrap();
27086        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27087        let catalog = active_registry().unwrap();
27088        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27089        let shared = NodeShared::new(
27090            handle,
27091            "fixture-token".to_owned(),
27092            NodeId::new("node-1").unwrap(),
27093            vec![workspace],
27094            vec![agent("claude")],
27095        );
27096
27097        let response = process_request_inner(
27098            &shared,
27099            77,
27100            ClientRole::Observer,
27101            NodeRequest::InspectWorkspace {
27102                workspace_id: workspace_id.clone(),
27103            },
27104        )
27105        .await
27106        .unwrap();
27107        let NodeResponse::WorkspaceInspected { inspection } = response else {
27108            panic!("workspace inspection returned another response");
27109        };
27110        assert_eq!(inspection.workspace_id, workspace_id);
27111        assert!(inspection.entries.iter().any(|entry| {
27112            entry.relative_path.as_utf8() == Some("src/main.rs")
27113        }));
27114
27115        let unknown = process_request_inner(
27116            &shared,
27117            77,
27118            ClientRole::Observer,
27119            NodeRequest::InspectWorkspace {
27120                workspace_id: WorkspaceId::new("unknown").unwrap(),
27121            },
27122        )
27123        .await
27124        .unwrap_err();
27125        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27126        std::fs::remove_dir_all(&root).unwrap();
27127    }
27128
27129    #[test]
27130    fn context_pack_source_accepts_only_running_or_successful_exit() {
27131        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27132        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27133            exit_code: Some(0),
27134        }));
27135        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27136            exit_code: Some(1),
27137        }));
27138        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27139            exit_code: None,
27140        }));
27141        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27142            message: "provider failed".to_owned(),
27143        }));
27144        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27145        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27146        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27147    }
27148
27149    #[test]
27150    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27151        let git = git_snapshot_for_parser();
27152        let first = ContextPackRepositoryObservation {
27153            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27154            git: git.clone(),
27155            files: Vec::new(),
27156        };
27157        let second = ContextPackRepositoryObservation {
27158            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27159            git,
27160            files: Vec::new(),
27161        };
27162
27163        let error = stable_context_pack_repository(first, second).unwrap_err();
27164
27165        assert_eq!(
27166            error.code,
27167            NodeFailureCode::ContextPackMaterializationFailed,
27168        );
27169    }
27170
27171    #[test]
27172    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27173        let git = git_snapshot_for_parser();
27174        let first = ContextPackRepositoryObservation {
27175            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27176            git: git.clone(),
27177            files: vec![ContextPackRepositoryFileSource::utf8(
27178                "README.md",
27179                "first\n".to_owned(),
27180                6,
27181            )],
27182        };
27183        let second = ContextPackRepositoryObservation {
27184            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27185            git,
27186            files: vec![ContextPackRepositoryFileSource::utf8(
27187                "README.md",
27188                "other\n".to_owned(),
27189                6,
27190            )],
27191        };
27192
27193        let error = stable_context_pack_repository(first, second).unwrap_err();
27194
27195        assert_eq!(
27196            error.code,
27197            NodeFailureCode::ContextPackMaterializationFailed,
27198        );
27199    }
27200
27201    #[tokio::test]
27202    async fn native_session_catalog_zero_active_session_fixture() {
27203        let workspace_root = temporary_workspace_root("native-catalog-workspace");
27204        let history_root = temporary_workspace_root("native-catalog-history");
27205        let outside_root = temporary_workspace_root("native-catalog-outside");
27206        std::fs::create_dir_all(&workspace_root).unwrap();
27207        std::fs::create_dir_all(&history_root).unwrap();
27208        std::fs::create_dir_all(&outside_root).unwrap();
27209        let transcript = |session_id: &str, cwd: &Path, title: &str| {
27210            [
27211                serde_json::json!({
27212                    "type": "user",
27213                    "sessionId": session_id,
27214                    "cwd": cwd.to_str().unwrap(),
27215                    "message": { "content": "raw user text must not cross the catalog wire" }
27216                }),
27217                serde_json::json!({
27218                    "type": "assistant",
27219                    "sessionId": session_id,
27220                    "cwd": cwd.to_str().unwrap(),
27221                    "message": { "model": "claude-sonnet", "content": "raw answer" }
27222                }),
27223                serde_json::json!({
27224                    "type": "ai-title",
27225                    "sessionId": session_id,
27226                    "aiTitle": title
27227                }),
27228            ]
27229            .into_iter()
27230            .map(|record| record.to_string())
27231            .collect::<Vec<_>>()
27232            .join("\n")
27233        };
27234        std::fs::write(
27235            history_root.join("stable-session.jsonl"),
27236            transcript("stable-session", &workspace_root, "Workspace review"),
27237        )
27238        .unwrap();
27239        std::fs::write(
27240            history_root.join("outside-session.jsonl"),
27241            transcript("outside-session", &outside_root, "Outside review"),
27242        )
27243        .unwrap();
27244        let history = NativeHistoryConfig::new(vec![
27245            NativeHistoryRoot::new(
27246                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27247                HistorySourceLayout::SingleNdjson,
27248                history_root.clone(),
27249            )
27250            .unwrap(),
27251        ])
27252        .unwrap();
27253        let catalog = active_registry().unwrap();
27254        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27255        assert!(handle.snapshot().sessions.is_empty());
27256        let workspace_id = WorkspaceId::new("primary").unwrap();
27257        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27258        let mut shared = NodeShared::new(
27259            handle,
27260            "fixture-token".to_owned(),
27261            NodeId::new("node-native-catalog").unwrap(),
27262            vec![workspace],
27263            vec![agent("claude")],
27264        );
27265        shared.native_session_catalog = Some(Arc::new(Mutex::new(
27266            NativeSessionCatalogAuthority::new(history),
27267        )));
27268        let route = NativeSessionCatalogRoute::workspace(
27269            workspace_id.clone(),
27270            agent("claude"),
27271        );
27272        let request = NodeRequest::CatalogNativeSessions {
27273            route: route.clone(),
27274            limit: 2,
27275        };
27276        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27277            .await
27278            .unwrap_err();
27279        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27280        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27281            .await
27282            .unwrap();
27283        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27284            panic!("native catalog returned a different response");
27285        };
27286        let summary = summary.expect("node must emit native session catalog summary metadata");
27287        assert_eq!(entries.len(), 1);
27288        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27289        assert_eq!(entries[0].external_group, None);
27290        assert_eq!(entries[0].record_id, None);
27291        assert_eq!(entries[0].title, None);
27292        assert_eq!(entries[0].model, None);
27293        assert_eq!(entries[0].message_count, 0);
27294        let encoded_entries = serde_json::to_string(&entries).unwrap();
27295        assert!(!encoded_entries.contains("stable-session"));
27296        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27297        let page_request = NodeRequest::PageNativeSessions {
27298            route: route.clone(),
27299            window: crate::protocol::NativeSessionCatalogWindow::Recent,
27300            catalog_revision: summary.catalog_revision,
27301            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27302            after_selection_id: None,
27303            limit: 2,
27304        };
27305        let observer = process_request_inner(
27306            &shared,
27307            7,
27308            ClientRole::Observer,
27309            page_request.clone(),
27310        )
27311        .await
27312        .unwrap_err();
27313        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27314        let page_response = process_request_inner(
27315            &shared,
27316            7,
27317            ClientRole::Operator,
27318            page_request.clone(),
27319        )
27320        .await
27321        .unwrap();
27322        assert!(matches!(
27323            page_response,
27324            NodeResponse::NativeSessionsPaged { page, .. }
27325                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27326        ));
27327        let stale = process_request_inner(
27328            &shared,
27329            7,
27330            ClientRole::Operator,
27331            NodeRequest::PageNativeSessions {
27332                route: route.clone(),
27333                window: crate::protocol::NativeSessionCatalogWindow::Recent,
27334                catalog_revision: summary.catalog_revision.wrapping_add(1),
27335                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27336                after_selection_id: None,
27337                limit: 2,
27338            },
27339        )
27340        .await
27341        .unwrap_err();
27342        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27343        let selection = NativeSessionSelection {
27344            route: route.clone(),
27345            catalog_revision: summary.catalog_revision,
27346            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27347            selection_id: entries[0].selection_id.clone(),
27348        };
27349        let preview_response = process_request_inner(
27350            &shared,
27351            7,
27352            ClientRole::Operator,
27353            NodeRequest::PreviewNativeSession {
27354                selection: selection.clone(),
27355                message_limit: 1,
27356            },
27357        )
27358        .await
27359        .unwrap();
27360        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27361            panic!("native preview returned a different response");
27362        };
27363        assert_eq!(preview.messages.len(), 1);
27364        assert!(preview.message_count_exact);
27365        assert_eq!(preview.messages[0].text, "raw answer");
27366        assert!(preview.truncated);
27367
27368        let index_request = NodeRequest::IndexNativeSession {
27369            selection: selection.clone(),
27370            display_name: "Saved Claude".to_owned(),
27371        };
27372        let observer = process_request_inner(
27373            &shared,
27374            7,
27375            ClientRole::Observer,
27376            index_request.clone(),
27377        )
27378        .await
27379        .unwrap_err();
27380        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27381        let without_controller = process_request_inner(
27382            &shared,
27383            7,
27384            ClientRole::Operator,
27385            index_request.clone(),
27386        )
27387        .await
27388        .unwrap_err();
27389        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27390        let acquired = process_request_inner(
27391            &shared,
27392            7,
27393            ClientRole::Operator,
27394            NodeRequest::AcquireController { lease_ms: 5_000 },
27395        )
27396        .await
27397        .unwrap();
27398        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27399        let indexed = process_request_inner(
27400            &shared,
27401            7,
27402            ClientRole::Operator,
27403            index_request,
27404        )
27405        .await
27406        .unwrap();
27407        assert!(indexed.native_session_index_contract_is_valid());
27408        let NodeResponse::NativeSessionIndexed {
27409            selection: echoed_selection,
27410            record,
27411        } = indexed else {
27412            panic!("native session index returned a different response");
27413        };
27414        assert_eq!(echoed_selection, selection);
27415        let recataloged = process_request_inner(
27416            &shared,
27417            7,
27418            ClientRole::Operator,
27419            NodeRequest::CatalogNativeSessions {
27420                route,
27421                limit: 2,
27422            },
27423        )
27424        .await
27425        .unwrap();
27426        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27427            panic!("native catalog returned a different response after indexing");
27428        };
27429        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27430
27431        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27432        let external_catalog = process_request_inner(
27433            &shared,
27434            7,
27435            ClientRole::Operator,
27436            NodeRequest::CatalogNativeSessions {
27437                route: unregistered_route.clone(),
27438                limit: 2,
27439            },
27440        )
27441        .await
27442        .unwrap();
27443        let external_json = serde_json::to_string(&external_catalog).unwrap();
27444        assert!(!external_json.contains("outside-session"));
27445        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27446        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27447        assert!(!external_json.contains("\"session_id\""));
27448        assert!(!external_json.contains("\"cwd\""));
27449        assert!(!external_json.contains("\"path\""));
27450        let NodeResponse::NativeSessionsCataloged {
27451            route: echoed_route,
27452            entries: external_entries,
27453            summary: Some(external_summary),
27454        } = external_catalog
27455        else {
27456            panic!("unregistered native catalog returned a different response");
27457        };
27458        assert_eq!(echoed_route, unregistered_route);
27459        assert_eq!(external_entries.len(), 1);
27460        assert_eq!(external_entries[0].record_id, None);
27461        let external_group = external_entries[0]
27462            .external_group
27463            .as_ref()
27464            .expect("unregistered session must have an external group");
27465        assert_eq!(
27466            external_group.kind,
27467            gate4agent_types::NativeSessionExternalGroupKind::Project,
27468        );
27469        assert!(external_group.group_id.starts_with("external-"));
27470        assert_eq!(
27471            external_group.display_name,
27472            outside_root.file_name().unwrap().to_string_lossy(),
27473        );
27474        let external_selection = NativeSessionSelection {
27475            route: unregistered_route,
27476            catalog_revision: external_summary.catalog_revision,
27477            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27478            selection_id: external_entries[0].selection_id.clone(),
27479        };
27480        let external_preview = process_request_inner(
27481            &shared,
27482            7,
27483            ClientRole::Operator,
27484            NodeRequest::PreviewNativeSession {
27485                selection: external_selection.clone(),
27486                message_limit: 1,
27487            },
27488        )
27489        .await
27490        .unwrap();
27491        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27492        assert!(!external_preview_json.contains("outside-session"));
27493        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27494        assert!(!external_preview_json.contains("\"session_id\""));
27495        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27496            panic!("unregistered native preview returned a different response");
27497        };
27498        assert_eq!(preview.messages.len(), 1);
27499        let external_index = process_request_inner(
27500            &shared,
27501            7,
27502            ClientRole::Operator,
27503            NodeRequest::IndexNativeSession {
27504                selection: external_selection,
27505                display_name: "External Claude".to_owned(),
27506            },
27507        )
27508        .await
27509        .unwrap_err();
27510        assert_eq!(
27511            external_index.code,
27512            NodeFailureCode::WorkspaceRegistrationRequired,
27513        );
27514        let record_response = process_request_inner(
27515            &shared,
27516            7,
27517            ClientRole::Operator,
27518            NodeRequest::PreviewSessionRecord {
27519                record_id: record.record_id.clone(),
27520                message_limit: 2,
27521            },
27522        )
27523        .await
27524        .unwrap();
27525        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27526            panic!("record preview returned a different response");
27527        };
27528        assert_eq!(record_id, record.record_id);
27529        assert_eq!(preview.messages.len(), 2);
27530        let encoded = serde_json::to_string(&preview).unwrap();
27531        assert!(!encoded.contains("stable-session"));
27532        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27533        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27534            panic!("record preview history summary returned a different response");
27535        };
27536        let summaries = events
27537            .iter()
27538            .filter_map(|envelope| match &envelope.event {
27539                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27540                    if record_id == &record.record_id =>
27541                {
27542                    Some(*summary)
27543                }
27544                _ => None,
27545            })
27546            .collect::<Vec<_>>();
27547        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27548        let summary_json = serde_json::to_string(&summaries).unwrap();
27549        for private in [
27550            "stable-session",
27551            "raw user text must not cross the catalog wire",
27552            "raw answer",
27553            "Workspace review",
27554            "claude-sonnet",
27555        ] {
27556            assert!(!summary_json.contains(private), "{private}");
27557        }
27558        let identity = record.provider_session.clone().unwrap();
27559        assert_eq!(
27560            shared.revalidate_session_record_preview(&record, &identity),
27561            Ok(())
27562        );
27563        shared.remove_record_memory(&record.record_id);
27564        assert_eq!(
27565            shared
27566                .revalidate_session_record_preview(&record, &identity)
27567                .unwrap_err()
27568                .code,
27569            NodeFailureCode::UnknownSessionRecord
27570        );
27571        let released = process_request_inner(
27572            &shared,
27573            7,
27574            ClientRole::Operator,
27575            NodeRequest::ReleaseController,
27576        )
27577        .await
27578        .unwrap();
27579        assert!(matches!(released, NodeResponse::Controller { controller: None }));
27580        assert!(shared.controller_state().is_none());
27581        std::fs::remove_dir_all(workspace_root).unwrap();
27582        std::fs::remove_dir_all(history_root).unwrap();
27583        std::fs::remove_dir_all(outside_root).unwrap();
27584    }
27585
27586    #[tokio::test]
27587    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27588        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27589        std::fs::create_dir_all(&history_root).unwrap();
27590        let history = NativeHistoryConfig::new(vec![
27591            NativeHistoryRoot::new(
27592                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27593                HistorySourceLayout::SingleNdjson,
27594                history_root.clone(),
27595            )
27596            .unwrap(),
27597        ])
27598        .unwrap();
27599        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27600        let held = catalog.lock().unwrap();
27601        let result = timeout(
27602            Duration::from_secs(1),
27603            run_native_session_catalog_operation(
27604                Arc::clone(&catalog),
27605                "native session catalog",
27606                |_| Ok::<(), std::convert::Infallible>(()),
27607            ),
27608        )
27609        .await
27610        .expect("concurrent native catalog request must not wait for the operation deadline")
27611        .unwrap_err();
27612        assert_eq!(result.code, NodeFailureCode::BackendBusy);
27613        drop(held);
27614        std::fs::remove_dir_all(history_root).unwrap();
27615    }
27616
27617    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27618        let unique = std::time::SystemTime::now()
27619            .duration_since(std::time::UNIX_EPOCH)
27620            .unwrap()
27621            .as_nanos();
27622        std::env::temp_dir().join(format!(
27623            "gate4agent-node-{label}-{}-{unique}",
27624            std::process::id(),
27625        ))
27626    }
27627
27628    fn git_snapshot_for_parser() -> GitSnapshot {
27629        GitSnapshot {
27630            is_repository: true,
27631            branch: Some("main".to_owned()),
27632            status: Vec::new(),
27633            recent_commits: Vec::new(),
27634            worktrees: Vec::new(),
27635            managed_worktree: None,
27636            truncated: false,
27637            diagnostic: None,
27638        }
27639    }
27640
27641    #[tokio::test]
27642    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27643        let root = temporary_workspace_root("managed-git-scope");
27644        std::fs::create_dir_all(&root).unwrap();
27645        run_test_git(&root, &["init"]);
27646        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27647        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27648        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27649        run_test_git(&root, &["add", "seed.txt"]);
27650        run_test_git(&root, &["commit", "-m", "seed"]);
27651        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27652
27653        let workspace_id = WorkspaceId::new("managed-a").unwrap();
27654        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27655        let catalog = active_registry().unwrap();
27656        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27657        let shared = NodeShared::new(
27658            handle,
27659            "fixture-token".to_owned(),
27660            NodeId::new("node-1").unwrap(),
27661            vec![workspace],
27662            vec![agent("claude")],
27663        );
27664        let mut lease = ManagedWorktreeLeaseRecord {
27665            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27666            source_workspace_id: WorkspaceId::new("primary").unwrap(),
27667            workspace_id: workspace_id.clone(),
27668            profile_id: WorktreeProfileId::new("default").unwrap(),
27669            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27670            target_root: root.to_string_lossy().into_owned(),
27671            branch: "gate4agent/a".to_owned(),
27672            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27673            expected_head: None,
27674            retention: ManagedWorktreeRetention::Retain,
27675            state: ManagedWorktreeLeaseState::InUse,
27676            session_holders: vec![ManagedWorktreeSessionHolder {
27677                incarnation_id: shared.incarnation_id,
27678                instance_id: AgentInstanceId(7),
27679                generation: SessionGeneration(1),
27680            }],
27681            record_holders: Vec::new(),
27682            cleanup_failure: None,
27683            created_at_unix_ms: 1,
27684            updated_at_unix_ms: 1,
27685        };
27686        *shared
27687            .managed_worktrees
27688            .lock()
27689            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27690            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27691
27692        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27693        let scope = inspection.git.managed_worktree.unwrap();
27694        assert_eq!(scope.lease_id, lease.lease_id);
27695        assert_eq!(scope.branch, "gate4agent/a");
27696        assert_eq!(scope.active_session_count, 1);
27697
27698        lease.branch = "gate4agent/b".to_owned();
27699        *shared
27700            .managed_worktrees
27701            .lock()
27702            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27703            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27704        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27705        assert!(mismatch.git.managed_worktree.is_none());
27706        std::fs::remove_dir_all(root).unwrap();
27707    }
27708
27709    #[tokio::test]
27710    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27711        let root = temporary_workspace_root("workspace-file-write-controller");
27712        std::fs::create_dir_all(root.join("src")).unwrap();
27713        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27714        let workspace_id = WorkspaceId::new("primary").unwrap();
27715        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27716        let catalog = active_registry().unwrap();
27717        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27718        let shared = NodeShared::new(
27719            handle,
27720            "fixture-token".to_owned(),
27721            NodeId::new("node-1").unwrap(),
27722            vec![workspace],
27723            vec![agent("claude")],
27724        );
27725        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27726        let expected_revision = workspace_file_revision(b"old\n");
27727        let request = NodeRequest::WriteWorkspaceFile {
27728            workspace_id: workspace_id.clone(),
27729            path: path.clone(),
27730            expected_revision: expected_revision.clone(),
27731            text: "new\n".to_owned(),
27732        };
27733        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27734            .await
27735            .unwrap_err();
27736        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27737        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27738            .await
27739            .unwrap_err();
27740        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27741        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27742        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27743        let NodeResponse::WorkspaceFileWritten { file } = response else {
27744            panic!("workspace file write returned another response");
27745        };
27746        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27747        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27748        let stale = process_request_inner(
27749            &shared,
27750            77,
27751            ClientRole::Operator,
27752            NodeRequest::WriteWorkspaceFile {
27753                workspace_id,
27754                path,
27755                expected_revision,
27756                text: "lost update\n".to_owned(),
27757            },
27758        )
27759        .await
27760        .unwrap_err();
27761        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27762        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27763        std::fs::remove_dir_all(root).unwrap();
27764    }
27765
27766    #[tokio::test]
27767    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27768        let root = temporary_workspace_root("workspace-entry-create-controller");
27769        std::fs::create_dir_all(root.join("src")).unwrap();
27770        let workspace_id = WorkspaceId::new("primary").unwrap();
27771        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27772        let catalog = active_registry().unwrap();
27773        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27774        let shared = NodeShared::new(
27775            handle,
27776            "fixture-token".to_owned(),
27777            NodeId::new("node-1").unwrap(),
27778            vec![workspace],
27779            vec![agent("claude")],
27780        );
27781        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27782        let request = NodeRequest::CreateWorkspaceFile {
27783            workspace_id: workspace_id.clone(),
27784            path: path.clone(),
27785        };
27786        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27787            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27788        }));
27789        assert!(!request_uses_unnegotiated_capability(
27790            &request,
27791            &baseline_capabilities().unwrap(),
27792        ));
27793        assert!(request_uses_unnegotiated_capability(&request, &[]));
27794        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27795            .await
27796            .unwrap_err();
27797        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27798        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27799            .await
27800            .unwrap_err();
27801        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27802        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27803
27804        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27805            .await
27806            .unwrap();
27807        let NodeResponse::WorkspaceFileCreated { file } = response else {
27808            panic!("workspace file create returned another response");
27809        };
27810        assert_eq!(file.workspace_id, workspace_id);
27811        assert_eq!(file.path, path);
27812        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27813        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27814
27815        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27816            .await
27817            .unwrap_err();
27818        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27819
27820        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27821        let response = process_request_inner(
27822            &shared,
27823            77,
27824            ClientRole::Operator,
27825            NodeRequest::CreateWorkspaceDirectory {
27826                workspace_id: workspace_id.clone(),
27827                path: directory_path.clone(),
27828            },
27829        )
27830        .await
27831        .unwrap();
27832        assert_eq!(
27833            response,
27834            NodeResponse::WorkspaceDirectoryCreated {
27835                workspace_id: workspace_id.clone(),
27836                entry: WorkspaceEntry {
27837                    relative_path: directory_path,
27838                    kind: WorkspaceEntryKind::Directory,
27839                },
27840            },
27841        );
27842        assert!(root.join("src/nested").is_dir());
27843
27844        let missing_parent = process_request_inner(
27845            &shared,
27846            77,
27847            ClientRole::Operator,
27848            NodeRequest::CreateWorkspaceFile {
27849                workspace_id: workspace_id.clone(),
27850                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27851            },
27852        )
27853        .await
27854        .unwrap_err();
27855        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27856        let non_directory_parent = process_request_inner(
27857            &shared,
27858            77,
27859            ClientRole::Operator,
27860            NodeRequest::CreateWorkspaceFile {
27861                workspace_id,
27862                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27863            },
27864        )
27865        .await
27866        .unwrap_err();
27867        assert_eq!(
27868            non_directory_parent.code,
27869            NodeFailureCode::RepositoryParentNotDirectory,
27870        );
27871        std::fs::remove_dir_all(root).unwrap();
27872    }
27873
27874    #[tokio::test]
27875    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27876        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27877        std::fs::create_dir_all(root.join("src")).unwrap();
27878        #[cfg(windows)]
27879        let root = std::fs::canonicalize(root).unwrap();
27880        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27881        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27882        let barrier = Arc::new(std::sync::Barrier::new(2));
27883        let done = Arc::new(AtomicBool::new(false));
27884        let task_commit_state = Arc::clone(&commit_state);
27885        let task_barrier = Arc::clone(&barrier);
27886        let task_done = Arc::clone(&done);
27887        let task_root = root.clone();
27888        let task = tokio::task::spawn_blocking(move || {
27889            task_barrier.wait();
27890            let result = create_workspace_file_on_disk(
27891                &task_root,
27892                &path,
27893                &task_commit_state,
27894            );
27895            task_done.store(true, Ordering::Release);
27896            result
27897        });
27898
27899        let error = settle_workspace_entry_create(
27900            task,
27901            Arc::clone(&commit_state),
27902            Duration::from_millis(10),
27903            "fixture deadline",
27904            "fixture task failed",
27905        )
27906        .await
27907        .unwrap_err();
27908        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27909        assert_eq!(
27910            commit_state.load(Ordering::Acquire),
27911            WORKSPACE_ENTRY_CREATE_CANCELED,
27912        );
27913
27914        barrier.wait();
27915        timeout(Duration::from_secs(1), async {
27916            while !done.load(Ordering::Acquire) {
27917                tokio::task::yield_now().await;
27918            }
27919        })
27920        .await
27921        .expect("canceled blocking create did not settle");
27922        assert!(!root.join("src/late.rs").exists());
27923        std::fs::remove_dir_all(root).unwrap();
27924    }
27925}
27926
27927#[cfg(test)]
27928mod provisional_spawn_tests {
27929    use super::*;
27930    use std::sync::Arc;
27931
27932    fn shared() -> NodeShared {
27933        let catalog = active_registry().unwrap();
27934        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27935        let workspace = WorkspaceConfig::new(
27936            WorkspaceId::new("primary").unwrap(),
27937            std::env::current_dir().unwrap(),
27938        )
27939        .unwrap()
27940        .with_worktree_service_mode(WorktreeServiceMode::Manual);
27941        NodeShared::new(
27942            handle,
27943            "fixture-token".to_owned(),
27944            NodeId::new("node-provisional-test").unwrap(),
27945            vec![workspace],
27946            vec![AgentId::new("claude").unwrap()],
27947        )
27948    }
27949
27950    /// Provisional draft map: sessionize by instance id only; 50 sequential
27951    /// mark/claim cycles never collide, and a claimed draft is never reaped.
27952    #[test]
27953    fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
27954        let shared = shared();
27955        let ws = WorkspaceId::new("primary").unwrap();
27956        let mut ids = Vec::with_capacity(50);
27957        for i in 1u64..=50 {
27958            let id = AgentInstanceId(i);
27959            ids.push(id);
27960            let address = SessionAddress {
27961                workspace_id: ws.clone(),
27962                session: SessionKey {
27963                    instance_id: id,
27964                    generation: SessionGeneration(1),
27965                },
27966            };
27967            shared.mark_spawn_provisional(address);
27968        }
27969        assert_eq!(
27970            shared
27971                .provisional_spawns
27972                .lock()
27973                .unwrap_or_else(|p| p.into_inner())
27974                .len(),
27975            50
27976        );
27977        for id in ids.iter().step_by(2) {
27978            shared.sessionize_spawn(*id);
27979        }
27980        assert_eq!(
27981            shared
27982                .provisional_spawns
27983                .lock()
27984                .unwrap_or_else(|p| p.into_inner())
27985                .len(),
27986            25
27987        );
27988        assert!(shared.take_due_provisional_spawns().is_empty());
27989        {
27990            let mut map = shared
27991                .provisional_spawns
27992                .lock()
27993                .unwrap_or_else(|p| p.into_inner());
27994            let past = Instant::now() - Duration::from_secs(30);
27995            for (_, (_, at)) in map.iter_mut() {
27996                *at = past;
27997            }
27998        }
27999        let due = shared.take_due_provisional_spawns();
28000        assert_eq!(due.len(), 25);
28001        let due_ids: std::collections::BTreeSet<_> =
28002            due.iter().map(|a| a.session.instance_id.0).collect();
28003        let expected: std::collections::BTreeSet<_> =
28004            ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28005        assert_eq!(due_ids, expected);
28006        assert!(shared
28007            .provisional_spawns
28008            .lock()
28009            .unwrap_or_else(|p| p.into_inner())
28010            .is_empty());
28011    }
28012
28013    /// Twenty parallel provisional marks must each get their own instance key;
28014    /// killing/sessionizing one id must not touch the others.
28015    #[test]
28016    fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28017        let shared = Arc::new(shared());
28018        let ws = WorkspaceId::new("primary").unwrap();
28019        let mut handles = Vec::new();
28020        for i in 1u64..=20 {
28021            let shared = Arc::clone(&shared);
28022            let ws = ws.clone();
28023            handles.push(std::thread::spawn(move || {
28024                let id = AgentInstanceId(i);
28025                let address = SessionAddress {
28026                    workspace_id: ws,
28027                    session: SessionKey {
28028                        instance_id: id,
28029                        generation: SessionGeneration(1),
28030                    },
28031                };
28032                shared.mark_spawn_provisional(address);
28033                id
28034            }));
28035        }
28036        let mut ids = Vec::new();
28037        for h in handles {
28038            ids.push(h.join().unwrap());
28039        }
28040        ids.sort_by_key(|id| id.0);
28041        assert_eq!(ids.len(), 20);
28042        ids.dedup();
28043        assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28044        assert_eq!(
28045            shared
28046                .provisional_spawns
28047                .lock()
28048                .unwrap_or_else(|p| p.into_inner())
28049                .len(),
28050            20
28051        );
28052        shared.sessionize_spawn(AgentInstanceId(7));
28053        let left: Vec<_> = shared
28054            .provisional_spawns
28055            .lock()
28056            .unwrap_or_else(|p| p.into_inner())
28057            .keys()
28058            .map(|id| id.0)
28059            .collect();
28060        assert!(!left.contains(&7));
28061        assert_eq!(left.len(), 19);
28062    }
28063}
28064
28065
28066#[cfg(unix)]
28067extern "C" {
28068    #[link_name = "geteuid"]
28069    fn libc_geteuid() -> u32;
28070}
28071
28072#[cfg(test)]
28073mod call_home_link_tests {
28074    use super::*;
28075
28076    #[test]
28077    fn call_home_backoff_is_capped_and_monotonic() {
28078        let mut last = Duration::ZERO;
28079        for attempt in 1..20 {
28080            let next = call_home_backoff(attempt);
28081            assert!(next >= last);
28082            assert!(next <= Duration::from_secs(8));
28083            last = next;
28084        }
28085        assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28086    }
28087
28088    #[tokio::test]
28089    async fn call_home_socket_gets_keepalive() {
28090        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28091        let addr = listener.local_addr().unwrap();
28092        let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28093        let client = client.unwrap();
28094        enable_call_home_keepalive(&client).unwrap();
28095        let sock = socket2::SockRef::from(&client);
28096        assert!(sock.keepalive().unwrap());
28097        #[cfg(any(target_os = "linux", target_os = "macos"))]
28098        assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28099    }
28100}