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                        if let Err(error) = accounts.write_session_file(&account) {
3965                            tracing::warn!(nick = %account.nick, %error, "m4a session file not written");
3966                        }
3967                        self.m4a_live
3968                            .lock()
3969                            .unwrap_or_else(|p| p.into_inner())
3970                            .insert(account.nick.clone(), (instance_id, provider.clone()));
3971                        Some(merged)
3972                    }
3973                    Err(error) => {
3974                        tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3975                        environment_overlay
3976                    }
3977                }
3978            }
3979            (SessionMode::Pty, Some(_), None, _) => {
3980                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
3981                environment_overlay
3982            }
3983            _ => environment_overlay,
3984        };
3985        let mut instance_overlay = environment_overlay
3986            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
3987            .transpose()?
3988            .flatten();
3989        Ok(PreparedSpawn {
3990            instance_overlay,
3991            environment_selection,
3992            materialization_guard,
3993            harness_mcp_overlay,
3994            #[cfg(feature = "dig2-station-probe")]
3995            browser_station_lease_guard,
3996            materialization_id,
3997        })
3998    }
3999}
4000
4001struct SessionRecords {
4002    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4003}
4004
4005struct SpawnIdempotencyCache {
4006    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4007}
4008
4009struct SpawnIdempotencyEntry {
4010    value: SpawnIdempotencyValue,
4011    expires_at: Instant,
4012}
4013
4014enum SpawnIdempotencyValue {
4015    Standard {
4016        spec: SpawnSpec,
4017        result: Result<ResolvedSpawnReceipt, NodeFailure>,
4018    },
4019    Managed {
4020        request: ManagedWorktreeSpawnRequest,
4021        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4022    },
4023    HarnessMcp,
4024}
4025
4026enum DurableManagedSpawnReplayDecision {
4027    Reserved,
4028    PendingLinked,
4029    Committed(ManagedWorktreeSpawnReceipt),
4030}
4031
4032impl SpawnIdempotencyValue {
4033    fn references_context(&self, context_id: &SpawnContextId) -> bool {
4034        match self {
4035            Self::Standard { result, .. } => result
4036                .as_ref()
4037                .ok()
4038                .and_then(|receipt| receipt.context.as_ref()),
4039            Self::Managed { result, .. } => result
4040                .as_ref()
4041                .ok()
4042                .and_then(|receipt| receipt.spawn.context.as_ref()),
4043            Self::HarnessMcp => None,
4044        }
4045        .is_some_and(|receipt| &receipt.id == context_id)
4046    }
4047}
4048
4049#[derive(Clone, Copy)]
4050struct ControllerLease {
4051    connection_id: u64,
4052    expires_at: Instant,
4053}
4054
4055struct NodeEventHistory {
4056    last_sequence: u64,
4057    replay_floor_sequence: u64,
4058    events: VecDeque<NodeEventEnvelope>,
4059    record_providers: BTreeMap<SessionRecordId, AgentId>,
4060    removed_record_providers: BTreeMap<u64, AgentId>,
4061}
4062
4063impl NodeEventHistory {
4064    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4065        Self {
4066            last_sequence: 0,
4067            replay_floor_sequence: 1,
4068            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4069            record_providers,
4070            removed_record_providers: BTreeMap::new(),
4071        }
4072    }
4073}
4074
4075impl NodeShared {
4076    /// A node-spawned grok PTY session gets its own leader socket, so its agent backend runs
4077    /// as a descendant of this session's process (with the session's own environment) and
4078    /// never attaches to an operator's shared leader. Same grok home, same login.
4079    fn session_grok_leader_socket(
4080        &self,
4081        provider: &AgentId,
4082        mode: SessionMode,
4083        instance_id: AgentInstanceId,
4084    ) -> Option<std::path::PathBuf> {
4085        if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4086            return None;
4087        }
4088        let root = self
4089            .m4a_accounts
4090            .as_ref()
4091            .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4092            .unwrap_or_else(std::env::temp_dir);
4093        let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4094        if let Some(previous) = self
4095            .grok_leaders
4096            .lock()
4097            .unwrap_or_else(|p| p.into_inner())
4098            .insert(instance_id, socket.clone())
4099        {
4100            crate::grok_leader::retire(&previous);
4101        }
4102        Some(socket)
4103    }
4104
4105    /// Stops this session's own grok leader, if it had one. Never touches any other leader.
4106    fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4107        let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4108        if let Some(socket) = socket {
4109            crate::grok_leader::retire(&socket);
4110        }
4111    }
4112
4113    fn new_with_incarnation(
4114        handle: Gate4AgentHandle,
4115        access_token: String,
4116        node_id: NodeId,
4117        incarnation_id: NodeIncarnationId,
4118        workspaces: Vec<WorkspaceConfig>,
4119        enabled_providers: Vec<AgentId>,
4120        provider_runtime_statuses: ProviderRuntimeStatuses,
4121        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4122        provider_contracts: Vec<ProviderContractSupport>,
4123        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4124        spawn_profiles: SpawnProfileRegistry,
4125        native_launch_profile_control: Option<NativeLaunchProfileControl>,
4126        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4127        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4128        state_path: Option<PathBuf>,
4129        records: Vec<ManagedSessionRecord>,
4130        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4131        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4132        materializations: Vec<MaterializationOwnershipRecord>,
4133        persistence_warning: Option<String>,
4134        input_settle_timeout_ms: u64,
4135    ) -> Self {
4136        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4137        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4138        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4139        let (harness_mcp_proxy_event_tx, _) =
4140            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4141        let record_providers = records
4142            .iter()
4143            .map(|record| (record.record_id.clone(), record.provider.clone()))
4144            .collect();
4145        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4146            .iter()
4147            .map(|workspace| {
4148                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4149            })
4150            .collect();
4151        for lease in &managed_worktrees {
4152            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4153        }
4154        let managed_worktree_profiles = workspaces.iter()
4155            .map(|workspace| (
4156                workspace.workspace_id.clone(),
4157                workspace.managed_worktree_profiles.clone(),
4158            ))
4159            .collect();
4160        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4161            managed_worktrees,
4162            managed_worktree_tombstones,
4163        ).expect("durable managed worktree registry was validated while loading");
4164        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4165        managed_worktrees.reattach_record_holders(
4166            &managed_worktree_record_holders(&records),
4167            unix_time_ms(),
4168        );
4169        Self {
4170            handle,
4171            access_token,
4172            node_id,
4173            incarnation_id,
4174            started_at_unix_ms: std::time::SystemTime::now()
4175                .duration_since(std::time::UNIX_EPOCH)
4176                .unwrap_or_default()
4177                .as_millis()
4178                .min(u64::MAX as u128) as u64,
4179            workspaces: RwLock::new(
4180                workspaces
4181                    .into_iter()
4182                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4183                    .collect(),
4184            ),
4185            worktree_service_modes,
4186            managed_worktree_profiles,
4187            managed_worktrees: Mutex::new(managed_worktrees),
4188            enabled_providers,
4189            provider_runtime_statuses,
4190            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4191            provider_runtime_monitor,
4192            provider_contracts,
4193            provider_adapter_contracts,
4194            spawn_profiles,
4195            environment_profiles: RwLock::new(BTreeMap::new()),
4196            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4197            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4198            #[cfg(feature = "dig2-station-probe")]
4199            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4200            bundle_catalog: RwLock::new(BundleCatalog::default()),
4201            delivery_store: Mutex::new(None),
4202            context_pack_store: Mutex::new(None),
4203            context_catalog: RwLock::new(ContextPackCatalog::default()),
4204            native_launch_profile_control,
4205            harness_mcp_registry: None,
4206            native_session_catalog,
4207            session_environment_materializer,
4208            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4209            m4a_live: Mutex::new(BTreeMap::new()),
4210            grok_leaders: Mutex::new(BTreeMap::new()),
4211            #[cfg(unix)]
4212            signer_proxy: std::sync::OnceLock::new(),
4213            materializations: Mutex::new(
4214                materializations
4215                    .into_iter()
4216                    .map(|record| (record.id().clone(), record))
4217                    .collect(),
4218            ),
4219            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4220                entries: BTreeMap::new(),
4221            }),
4222            managed_spawn_replays: Mutex::new(BTreeMap::new()),
4223            controller: Mutex::new(None),
4224            provisional_spawns: Mutex::new(BTreeMap::new()),
4225            pending_acp_options: Mutex::new(BTreeMap::new()),
4226            history: Mutex::new(NodeEventHistory::new(record_providers)),
4227            event_tx,
4228            terminal_event_tx,
4229            agent_stream_event_tx,
4230            harness_mcp_proxy_event_tx,
4231            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4232            pty_input_wake: tokio::sync::Notify::new(),
4233            pty_input_marks: Mutex::new(BTreeMap::new()),
4234            next_connection_id: AtomicU64::new(1),
4235            next_instance_id: AtomicU64::new(1),
4236            next_command_id: AtomicU64::new(1),
4237            shutdown: AtomicBool::new(false),
4238            shutdown_notify: Notify::new(),
4239            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4240            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4241            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4242            mutation_gate: AsyncMutex::new(()),
4243            session_bindings: Mutex::new(BTreeMap::new()),
4244            session_records: Mutex::new(SessionRecords {
4245                records: records
4246                    .into_iter()
4247                    .map(|record| (record.record_id.clone(), record))
4248                    .collect(),
4249            }),
4250            state_path,
4251            persistence_error: RwLock::new(
4252                persistence_warning
4253                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
4254            ),
4255            state_transaction: Mutex::new(()),
4256            input_settle_timeout_ms,
4257            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4258            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4259            shell_efficiency_profile: OnceLock::new(),
4260            native_session_gauge: AtomicUsize::new(0),
4261            connection_loop_iterations: AtomicU64::new(0),
4262            connection_events_sent: AtomicU64::new(0),
4263            rejected_commands_total: AtomicU64::new(0),
4264            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4265            drive_loop_iterations_total: AtomicU64::new(0),
4266            drive_loop_iterations_idle: AtomicU64::new(0),
4267            session_record_retention: SessionRecordRetentionConfig::default(),
4268            retired_records_total: AtomicU64::new(0),
4269            #[cfg(feature = "fixture")]
4270            fixture_semantic_hook_policy: false,
4271            #[cfg(feature = "fixture")]
4272            fixture_spawn_managed_worktree_v2_failure_probe:
4273                SpawnManagedWorktreeV2FailureProbe::default(),
4274        }
4275    }
4276
4277    #[cfg(test)]
4278    fn new(
4279        handle: Gate4AgentHandle,
4280        access_token: String,
4281        node_id: NodeId,
4282        workspaces: Vec<WorkspaceConfig>,
4283        enabled_providers: Vec<AgentId>,
4284    ) -> Self {
4285        Self::new_with_incarnation(
4286            handle,
4287            access_token,
4288            node_id,
4289            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4290            workspaces,
4291            enabled_providers,
4292            ProviderRuntimeStatuses::default(),
4293            None,
4294            Vec::new(),
4295            Vec::new(),
4296            SpawnProfileRegistry::default(),
4297            None,
4298            None,
4299            None,
4300            None,
4301            Vec::new(),
4302            Vec::new(),
4303            Vec::new(),
4304            Vec::new(),
4305            None,
4306            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4307        )
4308    }
4309
4310    fn persistence_error(&self) -> Option<String> {
4311        self.persistence_error
4312            .read()
4313            .unwrap_or_else(|poisoned| poisoned.into_inner())
4314            .clone()
4315    }
4316
4317    async fn admit_provider_runtime(
4318        &self,
4319        provider: &AgentId,
4320        requirement: ProviderRuntimeRequirement,
4321    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4322        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4323            let pipe_transport = monitor.supports_pipe_transport(provider);
4324            let acp_transport = monitor.supports_acp_transport(provider);
4325            let provider = provider.clone();
4326            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4327            let (status, admission) = timeout(
4328                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4329                refresh,
4330            )
4331            .await
4332            .map_err(|_| failure(
4333                NodeFailureCode::BackendBusy,
4334                "provider runtime probe exceeded its bounded deadline",
4335            ))?
4336            .map_err(|_| failure(
4337                NodeFailureCode::BackendOperationFailed,
4338                "provider runtime probe task failed",
4339            ))?;
4340            if let Some(status) = status {
4341                self
4342                    .provider_runtime_status_updates
4343                    .write()
4344                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4345                    .insert(status.provider().clone(), status);
4346            }
4347            admission.and_then(|policy| {
4348                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4349            })
4350        } else {
4351            // No live monitor means no catalog to consult -- there is
4352            // nothing here to confirm this provider declares a Pipe or ACP
4353            // transport, so Inline/Acp are rejected the same conservative
4354            // way an undeclared provider always was, rather than admitted
4355            // on trust.
4356            crate::provider_runtime::admit_status(
4357                &self.provider_runtime_statuses,
4358                provider,
4359                false,
4360                false,
4361                requirement,
4362            )
4363        };
4364        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4365        admission.map_err(|error| match error {
4366            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4367                NodeFailureCode::BackendOperationFailed,
4368                "provider launcher is unavailable",
4369            ),
4370            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4371                NodeFailureCode::UnsupportedCapability,
4372                "provider semantic capability is not verified",
4373            ),
4374            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4375                NodeFailureCode::BackendBusy,
4376                "provider runtime probe is already in progress",
4377            ),
4378        })
4379    }
4380
4381    /// Whether the catalog declares a Pipe transport for `provider` --
4382    /// the fact `require_policy` needs to admit or reject
4383    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4384    /// catalog to consult, so this stays conservative and rejects, matching
4385    /// `admit_provider_runtime`'s own no-monitor branch.
4386    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4387        match self.provider_runtime_monitor.as_deref() {
4388            Some(monitor) => monitor.supports_pipe_transport(provider),
4389            None => false,
4390        }
4391    }
4392
4393    /// Whether the catalog declares an ACP transport for `provider` -- the
4394    /// fact `require_policy` needs to admit or reject
4395    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4396    /// `provider_supports_pipe_transport` exactly, including the
4397    /// conservative no-monitor default.
4398    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4399        match self.provider_runtime_monitor.as_deref() {
4400            Some(monitor) => monitor.supports_acp_transport(provider),
4401            None => false,
4402        }
4403    }
4404
4405    /// Grants hook ingestion to a provider whose catalog entry declares a
4406    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4407    ///
4408    /// `admit_status`'s `RawPassthrough` arm hands back
4409    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4410    /// correct for everything it was written to answer, and wrong for hooks
4411    /// specifically. A hook event is not inferred from terminal behaviour,
4412    /// so it has nothing to do with whether a launcher probe verified this
4413    /// CLI version's PTY contract; the authority is the catalog declaring
4414    /// the adapter plus the ingress authenticating the route. Without this
4415    /// the node installs a provider's hooks, the CLI faithfully calls them,
4416    /// and every event is refused -- which is exactly the state grok was in.
4417    ///
4418    /// Applied to both admission paths so the monitor's own derivation and
4419    /// this coarse fallback cannot disagree about the same provider.
4420    fn admit_declared_hook_semantics(
4421        &self,
4422        provider: &AgentId,
4423        policy: ProviderRuntimePolicy,
4424    ) -> ProviderRuntimePolicy {
4425        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4426            return policy;
4427        }
4428        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4429            contract.provider == *provider && contract.family == AdapterFamily::Hook
4430        });
4431        if !declares_hook {
4432            return policy;
4433        }
4434        ProviderRuntimePolicy::new(
4435            policy.raw_pty_lifecycle,
4436            policy.semantic_readiness,
4437            policy.structured_prompt,
4438            policy.provider_session_identity,
4439            policy.semantic_resume,
4440            true,
4441        )
4442        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4443    }
4444
4445    /// Station network allowlist → provider launch overlay argv.
4446    ///
4447    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4448    ///   `provider_native` under Moderate (PTY or ACP).
4449    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4450    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4451    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4452    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4453    ///   no overlay.
4454    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4455    ///   empty overlay when no `provider_native`; invented native keys already
4456    ///   refuse at load / resolve.
4457    ///
4458    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4459    fn resolve_network_allowlist_overlay_args(
4460        &self,
4461        provider: &AgentId,
4462        mode: SessionMode,
4463        approval_level: ApprovalLevel,
4464        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4465        provider_home: Option<&Path>,
4466    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4467        let Some(allowlist_id) = network_allowlist else {
4468            return Ok(Vec::new());
4469        };
4470        let catalog = self
4471            .network_allowlist_catalog
4472            .read()
4473            .unwrap_or_else(|poisoned| poisoned.into_inner());
4474        let Some(entry) = catalog.get(allowlist_id) else {
4475            return Err(failure(
4476                NodeFailureCode::UnknownNetworkAllowlist,
4477                "spawn network allowlist is unavailable on this node",
4478            ));
4479        };
4480
4481        // Claude: settings-shaped overlay from permits (not provider_native).
4482        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4483            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4484                |message| {
4485                    failure(
4486                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4487                        &message,
4488                    )
4489                },
4490            )?;
4491            if !matches!(mode, SessionMode::Pty) {
4492                return Err(failure(
4493                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4494                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4495                ));
4496            }
4497            let Some(home) = provider_home else {
4498                return Err(failure(
4499                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4500                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4501                ));
4502            };
4503            let domains =
4504                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4505            if domains.is_empty() {
4506                return Err(failure(
4507                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4508                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4509                ));
4510            }
4511            let settings_path = home.join(
4512                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4513            );
4514            let json =
4515                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4516            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4517                |_| {
4518                    failure(
4519                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4520                        "Claude station network settings sidecar could not be written under ProviderHome",
4521                    )
4522                },
4523            )?;
4524            let overlay =
4525                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4526            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4527        }
4528
4529        let Some(native) = entry.provider_native.as_ref() else {
4530            return Ok(Vec::new());
4531        };
4532        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4533            provider.as_str(),
4534            approval_level,
4535            native,
4536        )
4537        .map_err(|message| {
4538            failure(
4539                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4540                &message,
4541            )
4542        })?;
4543        if overlay.is_empty() {
4544            return Ok(Vec::new());
4545        }
4546        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4547            return Err(failure(
4548                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4549                "Codex network_access -c overlay requires PTY or ACP transport",
4550            ));
4551        }
4552        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4553    }
4554
4555    /// Backward-compatible alias used by Codex-focused unit tests.
4556    fn resolve_codex_network_access_overlay_args(
4557        &self,
4558        provider: &AgentId,
4559        mode: SessionMode,
4560        approval_level: ApprovalLevel,
4561        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4562    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4563        self.resolve_network_allowlist_overlay_args(
4564            provider,
4565            mode,
4566            approval_level,
4567            network_allowlist,
4568            None,
4569        )
4570    }
4571
4572    fn resolve_environment_profile(
4573        &self,
4574        resolved: &ResolvedSpawnSpec,
4575    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4576        // Station network allowlist catalog enforce (plan
4577        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4578        // catalog — unknown ids refuse rather than silent ambient. Runs even
4579        // when no environment_profile_id is present so a bare allowlist still
4580        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4581        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4582            let catalog = self
4583                .network_allowlist_catalog
4584                .read()
4585                .unwrap_or_else(|poisoned| poisoned.into_inner());
4586            let Some(entry) = catalog.get(allowlist_id) else {
4587                return Err(failure(
4588                    NodeFailureCode::UnknownNetworkAllowlist,
4589                    "spawn network allowlist is unavailable on this node",
4590                ));
4591            };
4592            // Track B: refuse provider-native mappings this provider cannot
4593            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4594            if let Some(native) = entry.provider_native.as_ref() {
4595                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4596                    resolved.provider.as_str(),
4597                    resolved.approval_level,
4598                    native,
4599                ) {
4600                    return Err(failure(
4601                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4602                        &message,
4603                    ));
4604                }
4605            }
4606        }
4607        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4608        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4609        // cheap named-pipe path reachability must succeed (Windows
4610        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4611        // NodeFailureCode. Runs even without environment_profile_id (same
4612        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4613        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4614        // acquired later in spawn_session_with_deadline (after instance id).
4615        #[cfg(feature = "dig2-station-probe")]
4616        if resolved.browser_profile_id.is_some() {
4617            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4618            match probe_station_reachable() {
4619                Ok(()) => {}
4620                Err(StationProbeError::UnsupportedPlatform)
4621                | Err(StationProbeError::InvalidPipeSuffix) => {
4622                    return Err(failure(
4623                        NodeFailureCode::BrowserStationProbeUnavailable,
4624                        "dig2browser station probe unavailable on this platform",
4625                    ));
4626                }
4627                Err(StationProbeError::Unreachable) => {
4628                    return Err(failure(
4629                        NodeFailureCode::BrowserStationUnreachable,
4630                        "dig2browser station named-pipe is unreachable",
4631                    ));
4632                }
4633            }
4634        }
4635        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4636            return Ok(None);
4637        };
4638        let profiles = self
4639            .environment_profiles
4640            .read()
4641            .unwrap_or_else(|poisoned| poisoned.into_inner());
4642        let binding = profiles.get(profile_id).ok_or_else(|| {
4643            failure(
4644                NodeFailureCode::UnknownEnvironmentProfile,
4645                "spawn environment profile is unavailable on this node",
4646            )
4647        })?;
4648        if binding.provider != resolved.provider
4649            || binding.native_profile_id(resolved.mode).is_none()
4650        {
4651            return Err(failure(
4652                NodeFailureCode::EnvironmentProfileBindingMismatch,
4653                "spawn environment profile does not match the provider and mode",
4654            ));
4655        }
4656        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4657        // after catalog + optional dig2 probe above. Empty/whitespace ids
4658        // already refuse at Spawn*Id construction.
4659        Ok(Some(ResolvedEnvironmentProfileReceipt {
4660            profile_id: binding.id.clone(),
4661            profile_revision: binding.revision.clone(),
4662            network_allowlist: resolved.network_allowlist.clone(),
4663            browser_profile_id: resolved.browser_profile_id.clone(),
4664        }))
4665    }
4666
4667    fn resolve_bundle(
4668        &self,
4669        resolved: &ResolvedSpawnSpec,
4670        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4671    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4672        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4673            return Ok(None);
4674        };
4675        let catalog = self.bundle_catalog
4676            .read()
4677            .unwrap_or_else(|poisoned| poisoned.into_inner());
4678        let bundle = catalog.get(bundle_id).ok_or_else(|| {
4679                failure(
4680                    NodeFailureCode::UnknownBundle,
4681                    "spawn bundle is unavailable on this node",
4682                )
4683            })?;
4684        self.bundle_layout(
4685            &resolved.provider,
4686            resolved.mode,
4687            environment_profile,
4688            bundle,
4689        )?;
4690        Ok(Some(bundle.receipt()))
4691    }
4692
4693    fn begin_delivery_stage(
4694        &self,
4695        manifest: DeliveryBundleManifestV2,
4696    ) -> Result<NodeResponse, NodeFailure> {
4697        let mut store = self
4698            .delivery_store
4699            .lock()
4700            .unwrap_or_else(|poisoned| poisoned.into_inner());
4701        let store = store.as_mut().ok_or_else(|| {
4702            failure(
4703                NodeFailureCode::DeliveryStageStorageFailed,
4704                "node delivery store is unavailable",
4705            )
4706        })?;
4707        let begun = store.begin(manifest).map_err(delivery_failure)?;
4708        Ok(NodeResponse::DeliveryStageBegun {
4709            stage_id: begun.stage_id,
4710            manifest_digest: begun.manifest_digest,
4711            missing_blobs: begun.missing_blobs,
4712        })
4713    }
4714
4715    fn put_delivery_blob_chunk(
4716        &self,
4717        stage_id: DeliveryStageId,
4718        blob_digest: DeliveryBlobDigestV1,
4719        offset: u64,
4720        chunk_hex: DeliveryBlobChunkHexV1,
4721    ) -> Result<NodeResponse, NodeFailure> {
4722        let mut store = self
4723            .delivery_store
4724            .lock()
4725            .unwrap_or_else(|poisoned| poisoned.into_inner());
4726        let store = store.as_mut().ok_or_else(|| {
4727            failure(
4728                NodeFailureCode::DeliveryStageStorageFailed,
4729                "node delivery store is unavailable",
4730            )
4731        })?;
4732        let accepted = store
4733            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4734            .map_err(delivery_failure)?;
4735        Ok(NodeResponse::DeliveryBlobChunkAccepted {
4736            stage_id: accepted.stage_id,
4737            blob_digest: accepted.blob_digest,
4738            next_offset: accepted.next_offset,
4739        })
4740    }
4741
4742    fn commit_delivery_stage(
4743        &self,
4744        stage_id: DeliveryStageId,
4745    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4746        let mut store = self
4747            .delivery_store
4748            .lock()
4749            .unwrap_or_else(|poisoned| poisoned.into_inner());
4750        let store = store.as_mut().ok_or_else(|| {
4751            failure(
4752                NodeFailureCode::DeliveryStageStorageFailed,
4753                "node delivery store is unavailable",
4754            )
4755        })?;
4756        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4757        let mut catalog = self
4758            .bundle_catalog
4759            .write()
4760            .unwrap_or_else(|poisoned| poisoned.into_inner());
4761        let mut next_catalog = catalog.clone();
4762        next_catalog
4763            .insert_idempotent(prepared.bundle().clone())
4764            .map_err(|_| {
4765                failure(
4766                    NodeFailureCode::DeliveryStageConflict,
4767                    "delivery bundle identity conflicts with installed content",
4768                )
4769            })?;
4770        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4771        *catalog = next_catalog;
4772        Ok(receipt)
4773    }
4774
4775    fn abort_delivery_stage(
4776        &self,
4777        stage_id: &DeliveryStageId,
4778    ) -> Result<(), NodeFailure> {
4779        let mut store = self
4780            .delivery_store
4781            .lock()
4782            .unwrap_or_else(|poisoned| poisoned.into_inner());
4783        let store = store.as_mut().ok_or_else(|| {
4784            failure(
4785                NodeFailureCode::DeliveryStageStorageFailed,
4786                "node delivery store is unavailable",
4787            )
4788        })?;
4789        store.abort(stage_id).map_err(delivery_failure)
4790    }
4791
4792    fn resolve_context(
4793        &self,
4794        resolved: &ResolvedSpawnSpec,
4795    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4796        let Some(context_id) = resolved.context_id.as_ref() else {
4797            return Ok(None);
4798        };
4799        self.context_catalog
4800            .read()
4801            .unwrap_or_else(|poisoned| poisoned.into_inner())
4802            .get(context_id)
4803            .map(|context| context.receipt().clone())
4804            .ok_or_else(|| {
4805                failure(
4806                    NodeFailureCode::UnknownContextPack,
4807                    "spawn context pack is unavailable on this node",
4808                )
4809            })
4810            .map(Some)
4811    }
4812
4813    fn bundle_layout(
4814        &self,
4815        provider: &AgentId,
4816        mode: SessionMode,
4817        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4818        bundle: &NodeBundle,
4819    ) -> Result<BundleProviderLayout, NodeFailure> {
4820        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4821            failure(
4822                NodeFailureCode::BundleBindingMismatch,
4823                "spawn bundle does not match the provider and mode",
4824            )
4825        })?;
4826        if layout != BundleProviderLayout::Codex {
4827            return Ok(layout);
4828        }
4829        let profile = environment_profile.and_then(|receipt| {
4830            let profiles = self
4831                .environment_profiles
4832                .read()
4833                .unwrap_or_else(|poisoned| poisoned.into_inner());
4834            let binding = profiles.get(&receipt.profile_id)?;
4835            if binding.revision != receipt.profile_revision
4836                || &binding.provider != provider
4837                || binding.native_profile_id(mode).is_none()
4838            {
4839                return None;
4840            }
4841            self.environment_materialization_profiles
4842                .read()
4843                .unwrap_or_else(|poisoned| poisoned.into_inner())
4844                .get(&receipt.profile_id)
4845                .cloned()
4846        });
4847        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4848            return Err(failure(
4849                NodeFailureCode::BundleBindingMismatch,
4850                "Codex bundle requires an exact isolated CODEX_HOME profile",
4851            ));
4852        }
4853        Ok(layout)
4854    }
4855
4856    fn select_environment_profile(
4857        &self,
4858        instance_id: AgentInstanceId,
4859        provider: &AgentId,
4860        mode: SessionMode,
4861        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4862    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4863        let Some(environment_profile) = environment_profile else {
4864            return Ok(None);
4865        };
4866        let native_profile_id = {
4867            let profiles = self
4868                .environment_profiles
4869                .read()
4870                .unwrap_or_else(|poisoned| poisoned.into_inner());
4871            let binding = profiles
4872                .get(&environment_profile.profile_id)
4873                .ok_or_else(|| {
4874                    failure(
4875                        NodeFailureCode::UnknownEnvironmentProfile,
4876                        "session environment profile is unavailable on this node",
4877                    )
4878                })?;
4879            if &binding.revision != &environment_profile.profile_revision
4880                || &binding.provider != provider
4881            {
4882                return Err(failure(
4883                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4884                    "session environment profile revision or provider changed",
4885                ));
4886            }
4887            binding.native_profile_id(mode).cloned().ok_or_else(|| {
4888                failure(
4889                    NodeFailureCode::EnvironmentProfileBindingMismatch,
4890                    "session environment profile does not support this mode",
4891                )
4892            })?
4893        };
4894        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4895            failure(
4896                NodeFailureCode::BackendOperationFailed,
4897                "native environment profile controller is unavailable",
4898            )
4899        })?;
4900        control
4901            .select_native_launch_profile(instance_id, native_profile_id)
4902            .map_err(|_| {
4903                failure(
4904                    NodeFailureCode::BackendOperationFailed,
4905                    "native environment profile selection failed",
4906                )
4907            })?;
4908        Ok(Some(NativeEnvironmentSelectionGuard {
4909            control: Some(control),
4910            instance_id,
4911        }))
4912    }
4913
4914    fn install_prepared_launch_overlay(
4915        &self,
4916        instance_id: AgentInstanceId,
4917        overlay: PreparedNativeLaunchOverlay,
4918    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4919        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4920            failure(
4921                NodeFailureCode::BackendOperationFailed,
4922                "native launch profile controller is unavailable",
4923            )
4924        })?;
4925        match overlay {
4926            PreparedNativeLaunchOverlay::Environment(overlay) => {
4927                control
4928                    .install_native_launch_environment_overlay(instance_id, overlay)
4929                    .map_err(|_| failure(
4930                        NodeFailureCode::BackendOperationFailed,
4931                        "session environment overlay installation failed",
4932                    ))?;
4933                Ok(None)
4934            }
4935            PreparedNativeLaunchOverlay::Instance(overlay) => {
4936                control
4937                    .install_native_instance_launch_overlay(instance_id, overlay)
4938                    .map_err(|_| failure(
4939                        NodeFailureCode::BundleBindingMismatch,
4940                        "session bundle launch overlay installation failed",
4941                    ))?;
4942                Ok(Some(NativeInstanceOverlayGuard {
4943                    control: Some(control),
4944                    instance_id,
4945                }))
4946            }
4947        }
4948    }
4949
4950    fn environment_materialization_profile(
4951        &self,
4952        environment_profile: &ResolvedEnvironmentProfileReceipt,
4953    ) -> Option<NodeSessionMaterializationProfile> {
4954        self.environment_materialization_profiles
4955            .read()
4956            .unwrap_or_else(|poisoned| poisoned.into_inner())
4957            .get(&environment_profile.profile_id)
4958            .cloned()
4959    }
4960
4961    fn materialization_profile(
4962        &self,
4963        provider: &AgentId,
4964        mode: SessionMode,
4965        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4966        bundle: Option<&ResolvedBundleReceipt>,
4967        context: Option<&ResolvedContextPackReceipt>,
4968    ) -> Result<Option<(
4969        NodeSessionMaterializationProfile,
4970        bool,
4971        Option<BundleProviderLayout>,
4972    )>, NodeFailure> {
4973        let environment = environment_profile
4974            .and_then(|receipt| self.environment_materialization_profile(receipt));
4975        let (bundle, bundle_layout) = match bundle {
4976            Some(receipt) => {
4977                let catalog = self.bundle_catalog.read()
4978                    .unwrap_or_else(|poisoned| poisoned.into_inner());
4979                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4980                    NodeFailureCode::UnknownBundle,
4981                    "session bundle is unavailable on this node",
4982                ))?;
4983                if installed.receipt() != *receipt {
4984                    return Err(failure(
4985                        NodeFailureCode::BundleBindingMismatch,
4986                        "session bundle revision or digest changed",
4987                    ));
4988                }
4989                let layout = self.bundle_layout(
4990                    provider,
4991                    mode,
4992                    environment_profile,
4993                    installed,
4994                )?;
4995                (Some(installed.clone()), Some(layout))
4996            }
4997            None => (None, None),
4998        };
4999        let has_environment_overlay = environment.is_some() || context.is_some();
5000        let profile = match (environment, bundle.as_ref(), bundle_layout) {
5001            (None, None, None) => None,
5002            (Some(profile), None, None) => Some(Ok(profile)),
5003            (None, Some(bundle), Some(layout)) => {
5004                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5005            }
5006            (Some(profile), Some(bundle), Some(layout)) => {
5007                Some(profile.with_bundle(bundle, layout))
5008            }
5009            _ => {
5010                return Err(failure(
5011                    NodeFailureCode::BundleBindingMismatch,
5012                    "session bundle provider layout is unavailable",
5013                ));
5014            }
5015        };
5016        let profile = profile
5017            .transpose()
5018            .map_err(|_| failure(
5019                NodeFailureCode::BundleMaterializationFailed,
5020                "session bundle materialization profile is invalid",
5021            ))?;
5022        let profile = match (profile, context) {
5023            (Some(profile), Some(receipt)) => {
5024                let catalog = self.context_catalog
5025                    .read()
5026                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5027                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5028                    NodeFailureCode::UnknownContextPack,
5029                    "session context pack is unavailable on this node",
5030                ))?;
5031                if pack.receipt() != receipt {
5032                    return Err(failure(
5033                        NodeFailureCode::ContextPackMaterializationFailed,
5034                        "session context pack identity changed",
5035                    ));
5036                }
5037                profile.with_context(pack)
5038            }
5039            (None, Some(receipt)) => {
5040                let catalog = self.context_catalog
5041                    .read()
5042                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5043                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5044                    NodeFailureCode::UnknownContextPack,
5045                    "session context pack is unavailable on this node",
5046                ))?;
5047                if pack.receipt() != receipt {
5048                    return Err(failure(
5049                        NodeFailureCode::ContextPackMaterializationFailed,
5050                        "session context pack identity changed",
5051                    ));
5052                }
5053                NodeSessionMaterializationProfile::from_context(pack)
5054            }
5055            (Some(profile), None) => Ok(profile),
5056            (None, None) => return Ok(None),
5057        }
5058        .map_err(|_| failure(
5059            NodeFailureCode::ContextPackMaterializationFailed,
5060            "session context pack materialization profile is invalid",
5061        ))?;
5062        Ok(Some((profile, has_environment_overlay, bundle_layout)))
5063    }
5064
5065    fn prepare_session_materialization<'a>(
5066        &'a self,
5067        address: &SessionAddress,
5068        provider: &AgentId,
5069        mode: SessionMode,
5070        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5071        bundle: Option<&ResolvedBundleReceipt>,
5072        context: Option<&ResolvedContextPackReceipt>,
5073        managed_lease_id: Option<ManagedWorktreeLeaseId>,
5074    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5075        let Some((profile, has_environment_overlay, bundle_layout)) =
5076            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5077        else {
5078            return Ok(None);
5079        };
5080        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5081            failure(
5082                NodeFailureCode::BackendOperationFailed,
5083                "session environment materializer is unavailable",
5084            )
5085        })?;
5086        let materialization_failure_code = if context.is_some() {
5087            NodeFailureCode::ContextPackMaterializationFailed
5088        } else if bundle.is_some() {
5089            NodeFailureCode::BundleMaterializationFailed
5090        } else {
5091            NodeFailureCode::BackendOperationFailed
5092        };
5093        let id = MaterializationId::new(format!(
5094            "mat-{}-{}-{}",
5095            self.incarnation_id,
5096            address.session.instance_id.0,
5097            address.session.generation.0,
5098        ))
5099        .map_err(|_| {
5100            failure(
5101                NodeFailureCode::BackendOperationFailed,
5102                "session environment materialization identity failed",
5103            )
5104        })?;
5105        let owner = MaterializationOwner::Session {
5106            incarnation_id: self.incarnation_id,
5107            instance_id: address.session.instance_id,
5108            generation: address.session.generation,
5109        };
5110        let mut ownership = materializer
5111            .begin(
5112                id.clone(),
5113                environment_profile.cloned(),
5114                bundle.cloned(),
5115                context.cloned(),
5116                owner,
5117                managed_lease_id,
5118                &profile,
5119                unix_time_ms(),
5120            )
5121            .map_err(|_| {
5122                failure(
5123                    materialization_failure_code,
5124                    "session materialization preparation failed",
5125                )
5126            })?;
5127        {
5128            let _transaction = self
5129                .state_transaction
5130                .lock()
5131                .unwrap_or_else(|poisoned| poisoned.into_inner());
5132            let duplicate = self
5133                .materializations
5134                .lock()
5135                .unwrap_or_else(|poisoned| poisoned.into_inner())
5136                .insert(id.clone(), ownership.clone());
5137            if duplicate.is_some() {
5138                return Err(failure(
5139                    NodeFailureCode::BackendOperationFailed,
5140                    "session environment materialization identity conflict",
5141                ));
5142            }
5143            if let Err(error) = self.persist_state_locked() {
5144                self.materializations
5145                    .lock()
5146                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5147                    .remove(&id);
5148                return Err(persistence_failure(error));
5149            }
5150        }
5151        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5152            Ok(environment) => environment,
5153            Err(_) => {
5154                let transaction = self
5155                    .state_transaction
5156                    .lock()
5157                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5158                self.materializations
5159                    .lock()
5160                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5161                    .insert(id.clone(), ownership.clone());
5162                if self.persist_state_locked().is_err() {
5163                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5164                }
5165                drop(transaction);
5166                if ownership.state() == MaterializationState::CleanupRequired {
5167                    let _ = self.cleanup_materialization(&id);
5168                }
5169                return Err(failure(
5170                    materialization_failure_code,
5171                    "session materialization failed",
5172                ));
5173            }
5174        };
5175        {
5176            let _transaction = self
5177                .state_transaction
5178                .lock()
5179                .unwrap_or_else(|poisoned| poisoned.into_inner());
5180            self.materializations
5181                .lock()
5182                .unwrap_or_else(|poisoned| poisoned.into_inner())
5183                .insert(id.clone(), ownership.clone());
5184            if let Err(error) = self.persist_state_locked() {
5185                drop(_transaction);
5186                let _ = self.cleanup_materialization(&id);
5187                return Err(persistence_failure(error));
5188            }
5189        }
5190        let guard = SessionMaterializationGuard {
5191            shared: self,
5192            id: Some(id),
5193            provider_home: Some(ownership.provider_home().to_path_buf()),
5194        };
5195        let bundle_arguments = match bundle {
5196            Some(receipt) => {
5197                let catalog = self.bundle_catalog.read()
5198                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5199                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5200                    NodeFailureCode::UnknownBundle,
5201                    "session bundle is unavailable on this node",
5202                ))?;
5203                if installed.receipt() != *receipt {
5204                    return Err(failure(
5205                        NodeFailureCode::BundleBindingMismatch,
5206                        "session bundle revision or digest changed",
5207                    ));
5208                }
5209                let layout = bundle_layout.ok_or_else(|| failure(
5210                    NodeFailureCode::BundleBindingMismatch,
5211                    "materialized bundle provider layout is unavailable",
5212                ))?;
5213                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5214                    failure(
5215                        NodeFailureCode::BundleMaterializationFailed,
5216                        "session bundle byte revalidation failed",
5217                    )
5218                })?;
5219                Some(bundle_launch_arguments(
5220                    layout,
5221                    ownership.bundle_root(),
5222                    ownership.provider_home(),
5223                ).map_err(|_| failure(
5224                    NodeFailureCode::BundleBindingMismatch,
5225                    "session bundle launch binding is invalid",
5226                ))?)
5227            }
5228            None => None,
5229        };
5230        if let Some(receipt) = context {
5231            materializer
5232                .revalidate_context(&ownership, receipt)
5233                .map_err(|_| failure(
5234                    NodeFailureCode::ContextPackMaterializationFailed,
5235                    "session context pack byte revalidation failed",
5236                ))?;
5237        }
5238        let transport = match mode {
5239            SessionMode::Pty => TransportKind::Pty,
5240            SessionMode::Inline => TransportKind::Pipe,
5241            SessionMode::Acp => TransportKind::Acp,
5242        };
5243        let overlay = if let Some(extra_args) = bundle_arguments {
5244            Some(PreparedNativeLaunchOverlay::Instance(
5245                NativeInstanceLaunchOverlay::new(
5246                    provider.clone(),
5247                    transport,
5248                    if has_environment_overlay {
5249                        environment
5250                    } else {
5251                        Vec::new()
5252                    },
5253                    extra_args,
5254                )
5255                .map_err(|_| {
5256                    failure(
5257                        NodeFailureCode::BundleBindingMismatch,
5258                        "session bundle launch overlay is invalid",
5259                    )
5260                })?,
5261            ))
5262        } else if has_environment_overlay {
5263            let context_only = environment_profile.is_none()
5264                && bundle.is_none()
5265                && context.is_some();
5266            let overlay = if context_only {
5267                NativeLaunchEnvironmentOverlay::new_context_root(
5268                    provider.clone(),
5269                    transport,
5270                    environment,
5271                )
5272                .map_err(|_| {
5273                    failure(
5274                        NodeFailureCode::ContextPackMaterializationFailed,
5275                        "context-only launch overlay is invalid",
5276                    )
5277                })?
5278            } else {
5279                NativeLaunchEnvironmentOverlay::new(
5280                    provider.clone(),
5281                    transport,
5282                    environment,
5283                )
5284                .map_err(|_| {
5285                    failure(
5286                        NodeFailureCode::BackendOperationFailed,
5287                        "session environment overlay is invalid",
5288                    )
5289                })?
5290            };
5291            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5292        } else {
5293            None
5294        };
5295        Ok(Some((overlay, guard)))
5296    }
5297
5298    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5299        let Some(mut ownership) = self
5300            .materializations
5301            .lock()
5302            .unwrap_or_else(|poisoned| poisoned.into_inner())
5303            .get(id)
5304            .cloned()
5305        else {
5306            return Ok(());
5307        };
5308        if ownership.state() != MaterializationState::CleanupRequired {
5309            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5310                failure(
5311                    NodeFailureCode::BackendOperationFailed,
5312                    "session environment cleanup state is invalid",
5313                )
5314            })?;
5315        }
5316        {
5317            let _transaction = self
5318                .state_transaction
5319                .lock()
5320                .unwrap_or_else(|poisoned| poisoned.into_inner());
5321            let previous = self
5322                .materializations
5323                .lock()
5324                .unwrap_or_else(|poisoned| poisoned.into_inner())
5325                .insert(id.clone(), ownership.clone());
5326            if let Err(error) = self.persist_state_locked() {
5327                let mut materializations = self
5328                    .materializations
5329                    .lock()
5330                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5331                if let Some(previous) = previous {
5332                    materializations.insert(id.clone(), previous);
5333                }
5334                return Err(persistence_failure(error));
5335            }
5336        }
5337        let cleanup = self
5338            .session_environment_materializer
5339            .as_ref()
5340            .ok_or(())
5341            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5342        if cleanup.is_err() {
5343            let _ = ownership.mark_recovery_required(unix_time_ms());
5344            self.materializations
5345                .lock()
5346                .unwrap_or_else(|poisoned| poisoned.into_inner())
5347                .insert(id.clone(), ownership);
5348            if self.persist_state().is_err() {
5349                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5350            }
5351            return Err(failure(
5352                NodeFailureCode::BackendOperationFailed,
5353                "session environment cleanup requires recovery",
5354            ));
5355        }
5356        let _transaction = self
5357            .state_transaction
5358            .lock()
5359            .unwrap_or_else(|poisoned| poisoned.into_inner());
5360        self.materializations
5361            .lock()
5362            .unwrap_or_else(|poisoned| poisoned.into_inner())
5363            .remove(id);
5364        if let Err(error) = self.persist_state_locked() {
5365            self.materializations
5366                .lock()
5367                .unwrap_or_else(|poisoned| poisoned.into_inner())
5368                .insert(id.clone(), ownership);
5369            return Err(persistence_failure(error));
5370        }
5371        Ok(())
5372    }
5373
5374    fn resolve_record_materialization(
5375        &self,
5376        record_id: &SessionRecordId,
5377        provider: &AgentId,
5378        mode: SessionMode,
5379        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5380        bundle: Option<&ResolvedBundleReceipt>,
5381        context: Option<&ResolvedContextPackReceipt>,
5382    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5383        let ownership = {
5384            let materializations = self
5385                .materializations
5386                .lock()
5387                .unwrap_or_else(|poisoned| poisoned.into_inner());
5388            let mut matches = materializations.values().filter(|record| {
5389                record.owner()
5390                    == &MaterializationOwner::Record {
5391                        record_id: record_id.clone(),
5392                    }
5393            });
5394            let ownership = matches.next().cloned();
5395            if matches.next().is_some() {
5396                return Err(failure(
5397                    NodeFailureCode::BackendOperationFailed,
5398                    "managed session has conflicting environment ownership",
5399                ));
5400            }
5401            ownership
5402        };
5403        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5404            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5405                failure(
5406                    NodeFailureCode::ContextPackMaterializationFailed,
5407                    "session context materializer is unavailable",
5408                )
5409            })?;
5410            let pack = materializer
5411                .revalidate_context(ownership, receipt)
5412                .map_err(|_| {
5413                    self.mark_materialization_recovery_required(ownership.id());
5414                    failure(
5415                        NodeFailureCode::ContextPackMaterializationFailed,
5416                        "managed session context pack revalidation failed",
5417                    )
5418                })?;
5419            self.context_catalog
5420                .write()
5421                .unwrap_or_else(|poisoned| poisoned.into_inner())
5422                .insert(pack)
5423                .map_err(|_| failure(
5424                    NodeFailureCode::ContextPackMaterializationFailed,
5425                    "managed session context pack catalog restore failed",
5426                ))?;
5427        }
5428        let materialization = self.materialization_profile(
5429            provider,
5430            mode,
5431            environment_profile,
5432            bundle,
5433            context,
5434        )?;
5435        match (ownership, materialization) {
5436            (None, None) => Ok(None),
5437            (None, Some(_)) | (Some(_), None) => Err(failure(
5438                NodeFailureCode::BackendOperationFailed,
5439                "managed session materialization is unavailable",
5440            )),
5441            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5442                if ownership.environment_profile() != environment_profile
5443                    || ownership.bundle() != bundle
5444                    || ownership.context() != context
5445                    || ownership.state() != MaterializationState::Ready
5446                {
5447                    self.mark_materialization_recovery_required(ownership.id());
5448                    return Err(failure(
5449                        NodeFailureCode::BackendOperationFailed,
5450                        "managed session environment ownership requires recovery",
5451                    ));
5452                }
5453                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5454                    failure(
5455                        NodeFailureCode::BackendOperationFailed,
5456                        "session environment materializer is unavailable",
5457                    )
5458                })?;
5459                if materializer.revalidate(&ownership).is_err() {
5460                    self.mark_materialization_recovery_required(ownership.id());
5461                    return Err(failure(
5462                        NodeFailureCode::BackendOperationFailed,
5463                        "managed session environment revalidation failed",
5464                    ));
5465                }
5466                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5467                    let catalog = self.bundle_catalog.read()
5468                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5469                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5470                        return Err(failure(
5471                            NodeFailureCode::UnknownBundle,
5472                            "managed session bundle is unavailable",
5473                        ));
5474                    };
5475                    let layout = bundle_layout.ok_or_else(|| failure(
5476                        NodeFailureCode::BundleBindingMismatch,
5477                        "record bundle provider layout is unavailable",
5478                    ))?;
5479                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5480                        drop(catalog);
5481                        self.mark_materialization_recovery_required(ownership.id());
5482                        return Err(failure(
5483                            NodeFailureCode::BundleMaterializationFailed,
5484                            "managed session bundle revalidation failed",
5485                        ));
5486                    }
5487                    Some(bundle_launch_arguments(
5488                        layout,
5489                        ownership.bundle_root(),
5490                        ownership.provider_home(),
5491                    ).map_err(|_| failure(
5492                        NodeFailureCode::BundleBindingMismatch,
5493                        "managed session bundle launch binding is invalid",
5494                    ))?)
5495                } else {
5496                    None
5497                };
5498                if !has_environment_materialization {
5499                    let overlay = bundle_arguments.map(|extra_args| {
5500                        NativeInstanceLaunchOverlay::new(
5501                            provider.clone(),
5502                            TransportKind::Pty,
5503                            Vec::new(),
5504                            extra_args,
5505                        )
5506                        .map(PreparedNativeLaunchOverlay::Instance)
5507                        .map_err(|_| failure(
5508                            NodeFailureCode::BundleBindingMismatch,
5509                            "managed session bundle launch overlay is invalid",
5510                        ))
5511                    }).transpose()?;
5512                    return Ok(Some((ownership.id().clone(), overlay)));
5513                }
5514                let environment = match materializer.resolve_environment(&ownership, &profile) {
5515                    Ok(environment) => environment,
5516                    Err(
5517                        SessionEnvironmentMaterializeError::SecretUnavailable
5518                        | SessionEnvironmentMaterializeError::SecretDenied
5519                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5520                    ) => {
5521                        return Err(failure(
5522                            NodeFailureCode::BackendOperationFailed,
5523                            "managed session environment resolution failed",
5524                        ));
5525                    }
5526                    Err(
5527                        SessionEnvironmentMaterializeError::InvalidRoot
5528                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5529                        | SessionEnvironmentMaterializeError::Filesystem(_)
5530                        | SessionEnvironmentMaterializeError::Record(_),
5531                    ) => {
5532                        self.mark_materialization_recovery_required(ownership.id());
5533                        return Err(failure(
5534                            NodeFailureCode::BackendOperationFailed,
5535                            "managed session environment resolution failed",
5536                        ));
5537                    }
5538                };
5539                let transport = match mode {
5540                    SessionMode::Pty => TransportKind::Pty,
5541                    SessionMode::Inline => TransportKind::Pipe,
5542                    SessionMode::Acp => TransportKind::Acp,
5543                };
5544                let overlay = match bundle_arguments {
5545                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5546                        NativeInstanceLaunchOverlay::new(
5547                            provider.clone(),
5548                            transport,
5549                            environment,
5550                            extra_args,
5551                        )
5552                        .map_err(|_| failure(
5553                            NodeFailureCode::BundleBindingMismatch,
5554                            "managed session bundle launch overlay is invalid",
5555                        ))?,
5556                    ),
5557                    None => PreparedNativeLaunchOverlay::Environment(
5558                        NativeLaunchEnvironmentOverlay::new(
5559                            provider.clone(),
5560                            transport,
5561                            environment,
5562                        )
5563                        .map_err(|_| {
5564                            failure(
5565                                NodeFailureCode::BackendOperationFailed,
5566                                "managed session environment overlay is invalid",
5567                            )
5568                        })?,
5569                    ),
5570                };
5571                Ok(Some((ownership.id().clone(), Some(overlay))))
5572            }
5573        }
5574    }
5575
5576    fn destroy_record_materialization(
5577        &self,
5578        record_id: &SessionRecordId,
5579    ) -> Result<bool, NodeFailure> {
5580        let ids = self
5581            .materializations
5582            .lock()
5583            .unwrap_or_else(|poisoned| poisoned.into_inner())
5584            .values()
5585            .filter_map(|record| {
5586                (record.owner()
5587                    == &MaterializationOwner::Record {
5588                        record_id: record_id.clone(),
5589                    })
5590                .then(|| record.id().clone())
5591            })
5592            .collect::<Vec<_>>();
5593        if ids.len() > 1 {
5594            return Err(failure(
5595                NodeFailureCode::BackendOperationFailed,
5596                "managed session has conflicting environment ownership",
5597            ));
5598        }
5599        let Some(id) = ids.first().cloned() else {
5600            return Ok(false);
5601        };
5602        let (terminal_record, mut cleanup_ownership) = {
5603            let _transaction = self
5604                .state_transaction
5605                .lock()
5606                .unwrap_or_else(|poisoned| poisoned.into_inner());
5607            let original_record = self.record(record_id)?;
5608            let mut terminal_record = original_record.clone();
5609            terminal_record.provider_session = None;
5610            terminal_record.active_session = None;
5611            terminal_record.state = ManagedSessionState::Unavailable;
5612            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5613            terminal_record.updated_at_unix_ms = unix_time_ms();
5614            let original_ownership = self
5615                .materializations
5616                .lock()
5617                .unwrap_or_else(|poisoned| poisoned.into_inner())
5618                .get(&id)
5619                .cloned()
5620                .ok_or_else(|| {
5621                    failure(
5622                        NodeFailureCode::BackendOperationFailed,
5623                        "managed session environment ownership disappeared during cleanup",
5624                    )
5625                })?;
5626            let mut cleanup_ownership = original_ownership.clone();
5627            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5628                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5629                    failure(
5630                        NodeFailureCode::BackendOperationFailed,
5631                        "managed session environment cleanup state is invalid",
5632                    )
5633                })?;
5634            }
5635            self.session_records
5636                .lock()
5637                .unwrap_or_else(|poisoned| poisoned.into_inner())
5638                .records
5639                .insert(record_id.clone(), terminal_record.clone());
5640            self.materializations
5641                .lock()
5642                .unwrap_or_else(|poisoned| poisoned.into_inner())
5643                .insert(id.clone(), cleanup_ownership.clone());
5644            if let Err(error) = self.persist_state_locked() {
5645                self.session_records
5646                    .lock()
5647                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5648                    .records
5649                    .insert(record_id.clone(), original_record);
5650                self.materializations
5651                    .lock()
5652                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5653                    .insert(id.clone(), original_ownership);
5654                return Err(persistence_failure(error));
5655            }
5656            (terminal_record, cleanup_ownership)
5657        };
5658        let cleanup = self
5659            .session_environment_materializer
5660            .as_ref()
5661            .ok_or(())
5662            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5663        if cleanup.is_err() {
5664            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5665            self.materializations
5666                .lock()
5667                .unwrap_or_else(|poisoned| poisoned.into_inner())
5668                .insert(id.clone(), cleanup_ownership);
5669            if self.persist_state().is_err() {
5670                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5671            }
5672            return Err(failure(
5673                NodeFailureCode::BackendOperationFailed,
5674                "managed session environment cleanup requires recovery",
5675            ));
5676        }
5677        let _transaction = self
5678            .state_transaction
5679            .lock()
5680            .unwrap_or_else(|poisoned| poisoned.into_inner());
5681        self.remove_record_memory(record_id);
5682        self.materializations
5683            .lock()
5684            .unwrap_or_else(|poisoned| poisoned.into_inner())
5685            .remove(&id);
5686        if let Err(error) = self.persist_state_locked() {
5687            self.session_records
5688                .lock()
5689                .unwrap_or_else(|poisoned| poisoned.into_inner())
5690                .records
5691                .insert(record_id.clone(), terminal_record);
5692            self.materializations
5693                .lock()
5694                .unwrap_or_else(|poisoned| poisoned.into_inner())
5695                .insert(id, cleanup_ownership);
5696            return Err(persistence_failure(error));
5697        }
5698        Ok(true)
5699    }
5700
5701    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5702        let changed = {
5703            let mut materializations = self
5704                .materializations
5705                .lock()
5706                .unwrap_or_else(|poisoned| poisoned.into_inner());
5707            let Some(ownership) = materializations.get_mut(id) else {
5708                return;
5709            };
5710            ownership.mark_recovery_required(unix_time_ms()).is_ok()
5711        };
5712        if changed && self.persist_state().is_err() {
5713            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5714        }
5715    }
5716
5717    fn reconcile_materializations(&self) {
5718        let records = self
5719            .materializations
5720            .lock()
5721            .unwrap_or_else(|poisoned| poisoned.into_inner())
5722            .values()
5723            .cloned()
5724            .collect::<Vec<_>>();
5725        for mut ownership in records {
5726            let result = match (ownership.state(), ownership.owner()) {
5727                (MaterializationState::Preparing, _)
5728                | (MaterializationState::CleanupRequired, _) => {
5729                    self.cleanup_materialization(ownership.id())
5730                }
5731                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5732                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5733                        self.materializations
5734                            .lock()
5735                            .unwrap_or_else(|poisoned| poisoned.into_inner())
5736                            .insert(ownership.id().clone(), ownership.clone());
5737                        self.persist_state().map_err(persistence_failure)
5738                    } else {
5739                        Err(failure(
5740                            NodeFailureCode::BackendOperationFailed,
5741                            "session environment reconciliation failed",
5742                        ))
5743                    }
5744                }
5745                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5746                    .session_environment_materializer
5747                    .as_ref()
5748                    .ok_or_else(|| {
5749                        failure(
5750                            NodeFailureCode::BackendOperationFailed,
5751                            "session environment materializer is unavailable",
5752                        )
5753                    })
5754                    .and_then(|materializer| {
5755                        materializer.revalidate(&ownership).map_err(|_| failure(
5756                            NodeFailureCode::BackendOperationFailed,
5757                            "session materialization revalidation failed",
5758                        ))?;
5759                        let record = self.record(record_id)?;
5760                        if record.environment_profile.as_ref() != ownership.environment_profile()
5761                            || record.bundle.as_ref() != ownership.bundle()
5762                            || record.context.as_ref() != ownership.context()
5763                        {
5764                            return Err(failure(
5765                                NodeFailureCode::BackendOperationFailed,
5766                                "session materialization ownership changed after restart",
5767                            ));
5768                        }
5769                        if let Some(receipt) = ownership.bundle() {
5770                            let catalog = self.bundle_catalog.read()
5771                                .unwrap_or_else(|poisoned| poisoned.into_inner());
5772                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5773                                NodeFailureCode::UnknownBundle,
5774                                "session bundle is unavailable after restart",
5775                            ))?;
5776                            if bundle.receipt() != *receipt {
5777                                return Err(failure(
5778                                    NodeFailureCode::BundleBindingMismatch,
5779                                    "session bundle changed after restart",
5780                                ));
5781                            }
5782                            let layout = self.bundle_layout(
5783                                &record.provider,
5784                                record.mode,
5785                                record.environment_profile.as_ref(),
5786                                bundle,
5787                            )?;
5788                            materializer
5789                                .revalidate_bundle(&ownership, bundle, layout)
5790                                .map_err(|_| failure(
5791                                    NodeFailureCode::BundleMaterializationFailed,
5792                                    "session bundle revalidation failed",
5793                                ))?;
5794                        }
5795                        if let Some(receipt) = ownership.context() {
5796                            let pack = materializer
5797                                .revalidate_context(&ownership, receipt)
5798                                .map_err(|_| {
5799                                    failure(
5800                                        NodeFailureCode::ContextPackMaterializationFailed,
5801                                        "session context revalidation failed",
5802                                    )
5803                                })?;
5804                            self.context_catalog
5805                                .write()
5806                                .unwrap_or_else(|poisoned| poisoned.into_inner())
5807                                .insert(pack)
5808                                .map_err(|_| {
5809                                    failure(
5810                                        NodeFailureCode::ContextPackMaterializationFailed,
5811                                        "session context catalog restore failed",
5812                                    )
5813                                })?;
5814                        }
5815                        Ok(())
5816                    }),
5817                (MaterializationState::RecoveryRequired, _) => Ok(()),
5818            };
5819            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5820                let _ = ownership.mark_recovery_required(unix_time_ms());
5821                self.materializations
5822                    .lock()
5823                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5824                    .insert(ownership.id().clone(), ownership);
5825                if self.persist_state().is_err() {
5826                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5827                }
5828            }
5829        }
5830        let materialized_profiles = self
5831            .environment_materialization_profiles
5832            .read()
5833            .unwrap_or_else(|poisoned| poisoned.into_inner())
5834            .keys()
5835            .cloned()
5836            .collect::<Vec<_>>();
5837        let ownership = self
5838            .materializations
5839            .lock()
5840            .unwrap_or_else(|poisoned| poisoned.into_inner())
5841            .values()
5842            .cloned()
5843            .collect::<Vec<_>>();
5844        let changed = {
5845            let mut session_records = self
5846                .session_records
5847                .lock()
5848                .unwrap_or_else(|poisoned| poisoned.into_inner());
5849            let mut changed = false;
5850            for record in session_records.records.values_mut() {
5851                let environment_profile = record.environment_profile.as_ref();
5852                if environment_profile.is_none()
5853                    && record.bundle.is_none()
5854                    && record.context.is_none()
5855                {
5856                    continue;
5857                }
5858                if record.bundle.is_none()
5859                    && environment_profile.is_some_and(|profile| {
5860                        !materialized_profiles.contains(&profile.profile_id)
5861                    })
5862                {
5863                    continue;
5864                }
5865                let has_exact_ownership = ownership.iter().any(|candidate| {
5866                    candidate.state() == MaterializationState::Ready
5867                        && candidate.environment_profile() == environment_profile
5868                        && candidate.bundle() == record.bundle.as_ref()
5869                        && candidate.context() == record.context.as_ref()
5870                        && candidate.owner()
5871                            == &MaterializationOwner::Record {
5872                                record_id: record.record_id.clone(),
5873                            }
5874                });
5875                if !has_exact_ownership {
5876                    record.provider_session = None;
5877                    record.active_session = None;
5878                    record.state = ManagedSessionState::Unavailable;
5879                    record.last_error = Some(if record.bundle.is_some() {
5880                        "bundle-unavailable".to_owned()
5881                    } else if record.context.is_some() {
5882                        "context-unavailable".to_owned()
5883                    } else {
5884                        "environment-profile-unavailable".to_owned()
5885                    });
5886                    record.updated_at_unix_ms = unix_time_ms();
5887                    changed = true;
5888                }
5889            }
5890            changed
5891        };
5892        if changed && self.persist_state().is_err() {
5893            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5894        }
5895    }
5896
5897    fn replay_spawn_spec(
5898        &self,
5899        spec: &SpawnSpec,
5900        now: Instant,
5901    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5902        let mut cache = self
5903            .spawn_idempotency
5904            .lock()
5905            .unwrap_or_else(|poisoned| poisoned.into_inner());
5906        cache.entries.retain(|_, entry| entry.expires_at > now);
5907        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5908            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5909                return Err(failure(
5910                    NodeFailureCode::SpawnIdempotencyConflict,
5911                    "spawn idempotency key was reused across spawn kinds",
5912                ));
5913            };
5914            if existing != spec {
5915                return Err(failure(
5916                    NodeFailureCode::SpawnIdempotencyConflict,
5917                    "spawn idempotency key was reused with a different specification",
5918                ));
5919            }
5920            return Ok(Some(result.clone()));
5921        }
5922        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5923            return Err(failure(
5924                NodeFailureCode::SpawnIdempotencyCapacity,
5925                "spawn idempotency cache reached its bounded capacity",
5926            ));
5927        }
5928        Ok(None)
5929    }
5930
5931    fn remember_spawn_spec(
5932        &self,
5933        spec: SpawnSpec,
5934        result: Result<ResolvedSpawnReceipt, NodeFailure>,
5935        accepted_at: Instant,
5936    ) {
5937        self
5938            .spawn_idempotency
5939            .lock()
5940            .unwrap_or_else(|poisoned| poisoned.into_inner())
5941            .entries
5942            .insert(
5943                spec.idempotency_key.clone(),
5944                SpawnIdempotencyEntry {
5945                    value: SpawnIdempotencyValue::Standard { spec, result },
5946                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5947                },
5948            );
5949    }
5950
5951    fn claim_harness_mcp_spawn(
5952        &self,
5953        _reservation_id: &HarnessMcpReservationId,
5954        _activation_digest: &HarnessMcpActivationDigest,
5955        spec: &SpawnSpec,
5956        now: Instant,
5957    ) -> Result<(), NodeFailure> {
5958        let mut cache = self.spawn_idempotency.lock()
5959            .unwrap_or_else(|poisoned| poisoned.into_inner());
5960        cache.entries.retain(|_, entry| entry.expires_at > now);
5961        if cache.entries.contains_key(&spec.idempotency_key) {
5962            return Err(failure(
5963                NodeFailureCode::SpawnIdempotencyConflict,
5964                "spawn idempotency key was reused across spawn kinds",
5965            ));
5966        }
5967        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5968            return Err(failure(
5969                NodeFailureCode::SpawnIdempotencyCapacity,
5970                "spawn idempotency cache reached its bounded capacity",
5971            ));
5972        }
5973        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5974            value: SpawnIdempotencyValue::HarnessMcp,
5975            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5976        });
5977        Ok(())
5978    }
5979
5980    fn replay_managed_spawn(
5981        &self,
5982        request: &ManagedWorktreeSpawnRequest,
5983        now: Instant,
5984    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5985        let mut cache = self.spawn_idempotency.lock()
5986            .unwrap_or_else(|poisoned| poisoned.into_inner());
5987        cache.entries.retain(|_, entry| entry.expires_at > now);
5988        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5989            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5990                return Err(failure(
5991                    NodeFailureCode::SpawnIdempotencyConflict,
5992                    "spawn idempotency key was reused across spawn kinds",
5993                ));
5994            };
5995            if existing != request {
5996                return Err(failure(
5997                    NodeFailureCode::SpawnIdempotencyConflict,
5998                    "spawn idempotency key was reused with a different managed specification",
5999                ));
6000            }
6001            return Ok(Some(result.clone()));
6002        }
6003        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6004            return Err(failure(
6005                NodeFailureCode::SpawnIdempotencyCapacity,
6006                "spawn idempotency cache reached its bounded capacity",
6007            ));
6008        }
6009        Ok(None)
6010    }
6011
6012    fn remember_managed_spawn(
6013        &self,
6014        request: ManagedWorktreeSpawnRequest,
6015        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6016        accepted_at: Instant,
6017    ) {
6018        self.spawn_idempotency.lock()
6019            .unwrap_or_else(|poisoned| poisoned.into_inner())
6020            .entries
6021            .insert(
6022                request.spawn_spec.idempotency_key.clone(),
6023                SpawnIdempotencyEntry {
6024                    value: SpawnIdempotencyValue::Managed { request, result },
6025                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6026                },
6027            );
6028    }
6029
6030    fn remember_managed_spawn_attempt(
6031        &self,
6032        durable_v2_key: Option<&SpawnIdempotencyKey>,
6033        request: ManagedWorktreeSpawnRequest,
6034        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6035        accepted_at: Instant,
6036    ) {
6037        if durable_v2_key.is_none() {
6038            self.remember_managed_spawn(request, result, accepted_at);
6039        }
6040    }
6041
6042    fn acquire_durable_managed_spawn_v2(
6043        &self,
6044        request: &ManagedWorktreeSpawnRequestV2,
6045        request_digest: String,
6046    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6047        if self.state_path.is_none() {
6048            return Err(failure(
6049                NodeFailureCode::BackendOperationFailed,
6050                "managed worktree V2 requires durable node state",
6051            ));
6052        }
6053        let _transaction = self.state_transaction
6054            .lock()
6055            .unwrap_or_else(|poisoned| poisoned.into_inner());
6056        let key = request.spawn_spec.idempotency_key.clone();
6057        let mut previous = None;
6058        {
6059            let mut replays = self.managed_spawn_replays
6060                .lock()
6061                .unwrap_or_else(|poisoned| poisoned.into_inner());
6062            if let Some(record) = replays.get_mut(&key) {
6063                if record.request_digest != request_digest {
6064                    return Err(failure(
6065                        NodeFailureCode::SpawnIdempotencyConflict,
6066                        "managed worktree V2 idempotency key was reused with a different request",
6067                    ));
6068                }
6069                match &record.state {
6070                    ManagedWorktreeSpawnReplayStateV10::Pending {
6071                        owner_incarnation_id,
6072                        lease_id: None,
6073                    } if owner_incarnation_id != &self.incarnation_id => {
6074                        previous = Some(record.clone());
6075                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6076                            owner_incarnation_id: self.incarnation_id,
6077                            lease_id: None,
6078                        };
6079                        record.updated_at_unix_ms = unix_time_ms();
6080                    }
6081                    ManagedWorktreeSpawnReplayStateV10::Pending {
6082                        lease_id: None,
6083                        ..
6084                    } => {
6085                        return Err(failure(
6086                            NodeFailureCode::BackendBusy,
6087                            "managed worktree V2 reservation is already active",
6088                        ));
6089                    }
6090                    ManagedWorktreeSpawnReplayStateV10::Pending {
6091                        lease_id: Some(_),
6092                        ..
6093                    } => {
6094                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6095                    }
6096                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6097                        return Ok(DurableManagedSpawnReplayDecision::Committed(
6098                            receipt.clone(),
6099                        ));
6100                    }
6101                }
6102            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6103                return Err(failure(
6104                    NodeFailureCode::SpawnIdempotencyCapacity,
6105                    "durable managed worktree replay ledger reached its bounded capacity",
6106                ));
6107            } else {
6108                let now = unix_time_ms();
6109                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6110                    idempotency_key: key.clone(),
6111                    request_digest,
6112                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6113                    profile_id: request.worktree_profile_id.clone(),
6114                    expected_profile_revision: request.expected_profile_revision.clone(),
6115                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
6116                        owner_incarnation_id: self.incarnation_id,
6117                        lease_id: None,
6118                    },
6119                    created_at_unix_ms: now,
6120                    updated_at_unix_ms: now,
6121                });
6122            }
6123        }
6124        if let Err(error) = self.persist_state_locked() {
6125            let mut replays = self.managed_spawn_replays
6126                .lock()
6127                .unwrap_or_else(|poisoned| poisoned.into_inner());
6128            match previous {
6129                Some(previous) => {
6130                    replays.insert(key, previous);
6131                }
6132                None => {
6133                    replays.remove(&key);
6134                }
6135            }
6136            return Err(persistence_failure(error));
6137        }
6138        Ok(DurableManagedSpawnReplayDecision::Reserved)
6139    }
6140
6141    fn link_durable_managed_spawn_v2_lease_locked(
6142        &self,
6143        key: &SpawnIdempotencyKey,
6144        lease_id: &ManagedWorktreeLeaseId,
6145    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6146        let mut replays = self.managed_spawn_replays
6147            .lock()
6148            .unwrap_or_else(|poisoned| poisoned.into_inner());
6149        let record = replays.get_mut(key).ok_or_else(|| {
6150            failure(
6151                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6152                "managed worktree V2 reservation disappeared before allocation",
6153            )
6154        })?;
6155        let previous = record.clone();
6156        match &mut record.state {
6157            ManagedWorktreeSpawnReplayStateV10::Pending {
6158                lease_id: current,
6159                ..
6160            }
6161                if current.is_none() =>
6162            {
6163                *current = Some(lease_id.clone());
6164                record.updated_at_unix_ms = unix_time_ms();
6165                Ok(previous)
6166            }
6167            _ => Err(failure(
6168                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6169                "managed worktree V2 reservation is not allocation-ready",
6170            )),
6171        }
6172    }
6173
6174    fn restore_durable_managed_spawn_v2_locked(
6175        &self,
6176        key: &SpawnIdempotencyKey,
6177        previous: ManagedWorktreeSpawnReplayRecordV10,
6178    ) {
6179        self.managed_spawn_replays
6180            .lock()
6181            .unwrap_or_else(|poisoned| poisoned.into_inner())
6182            .insert(key.clone(), previous);
6183    }
6184
6185    fn commit_durable_managed_spawn_v2(
6186        &self,
6187        request: &ManagedWorktreeSpawnRequestV2,
6188        receipt: ManagedWorktreeSpawnReceipt,
6189    ) -> Result<(), NodeFailure> {
6190        let _transaction = self.state_transaction
6191            .lock()
6192            .unwrap_or_else(|poisoned| poisoned.into_inner());
6193        let key = &request.spawn_spec.idempotency_key;
6194        let previous = {
6195            let mut replays = self.managed_spawn_replays
6196                .lock()
6197                .unwrap_or_else(|poisoned| poisoned.into_inner());
6198            let record = replays.get_mut(key).ok_or_else(|| {
6199                failure(
6200                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6201                    "managed worktree V2 reservation disappeared before commit",
6202                )
6203            })?;
6204            let previous = record.clone();
6205            match &record.state {
6206                ManagedWorktreeSpawnReplayStateV10::Pending {
6207                    lease_id: Some(lease_id),
6208                    ..
6209                } if lease_id == &receipt.lease.lease_id => {}
6210                _ => {
6211                    return Err(failure(
6212                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
6213                        "managed worktree V2 reservation is not linked to the spawned lease",
6214                    ));
6215                }
6216            }
6217            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6218            record.updated_at_unix_ms = unix_time_ms();
6219            previous
6220        };
6221        if let Err(error) = self.persist_state_locked() {
6222            self.restore_durable_managed_spawn_v2_locked(key, previous);
6223            return Err(persistence_failure(error));
6224        }
6225        Ok(())
6226    }
6227
6228    fn remove_unallocated_durable_managed_spawn_v2(
6229        &self,
6230        request: &ManagedWorktreeSpawnRequestV2,
6231    ) -> Result<(), NodeFailure> {
6232        let _transaction = self.state_transaction
6233            .lock()
6234            .unwrap_or_else(|poisoned| poisoned.into_inner());
6235        let key = &request.spawn_spec.idempotency_key;
6236        let removed = {
6237            let mut replays = self.managed_spawn_replays
6238                .lock()
6239                .unwrap_or_else(|poisoned| poisoned.into_inner());
6240            match replays.get(key).map(|record| &record.state) {
6241                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6242                    lease_id: None,
6243                    ..
6244                }) => {
6245                    replays.remove(key)
6246                }
6247                _ => None,
6248            }
6249        };
6250        let Some(removed) = removed else {
6251            return Ok(());
6252        };
6253        if let Err(error) = self.persist_state_locked() {
6254            self.managed_spawn_replays
6255                .lock()
6256                .unwrap_or_else(|poisoned| poisoned.into_inner())
6257                .insert(key.clone(), removed);
6258            return Err(persistence_failure(error));
6259        }
6260        Ok(())
6261    }
6262
6263    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6264        if spec.target.node_id != self.node_id {
6265            return Err(failure(
6266                NodeFailureCode::SpawnTargetMismatch,
6267                "spawn target does not match this node",
6268            ));
6269        }
6270        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6271            failure(
6272                NodeFailureCode::UnknownSpawnProfile,
6273                "spawn profile is unavailable on this node",
6274            )
6275        })?;
6276        let resolved = spec.resolve(defaults).map_err(|error| match error {
6277            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6278                NodeFailureCode::SpawnProfileRevisionMismatch,
6279                "spawn profile revision does not match the loaded profile",
6280            ),
6281            _ => failure(
6282                NodeFailureCode::InvalidRequest,
6283                "spawn specification could not be resolved",
6284            ),
6285        })?;
6286        if resolved.target.worktree_id.is_some() {
6287            self.require_worktree_service(&resolved.target.workspace_id)?;
6288        }
6289        let environment_profile = self.resolve_environment_profile(&resolved)?;
6290        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6291        self.resolve_context(&resolved)?;
6292        Ok(resolved)
6293    }
6294
6295    async fn resolve_spawn_workspace(
6296        &self,
6297        resolved: &ResolvedSpawnSpec,
6298        deadline: Instant,
6299    ) -> Result<WorkspaceId, NodeFailure> {
6300        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6301            return Ok(resolved.target.workspace_id.clone());
6302        };
6303        if self.managed_worktrees.lock()
6304            .unwrap_or_else(|poisoned| poisoned.into_inner())
6305            .lease_for_workspace(worktree_id)
6306            .is_some()
6307        {
6308            return Err(failure(
6309                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6310                "managed worktree targets may only be spawned through their lease request",
6311            ));
6312        }
6313        self.require_worktree_service(&resolved.target.workspace_id)?;
6314        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6315        let selected_root = self.workspace_root(worktree_id)?;
6316        let remaining = spawn_deadline_remaining(deadline)?;
6317        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6318            .await
6319            .map_err(|_| {
6320                failure(
6321                    NodeFailureCode::SpawnDeadlineExceeded,
6322                    "Git worktree selection exceeded the spawn deadline",
6323                )
6324            })?
6325            .map_err(git_worktree_failure)?;
6326        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6327        Ok(worktree_id.clone())
6328    }
6329
6330    async fn spawn_from_spec(
6331        &self,
6332        spec: SpawnSpec,
6333    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6334        self.spawn_from_spec_with_proxy(spec, None).await
6335    }
6336
6337    async fn spawn_from_spec_with_proxy(
6338        &self,
6339        spec: SpawnSpec,
6340        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6341    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6342        let accepted_at = Instant::now();
6343        if harness_mcp.is_none() {
6344            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6345                return replayed;
6346            }
6347        }
6348        let resolved = self.resolve_spawn_spec(&spec)?;
6349        let environment_profile = self.resolve_environment_profile(&resolved)?;
6350        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6351        let context = self.resolve_context(&resolved)?;
6352        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6353        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6354        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6355        let spawn_workspace_id = match spawn_workspace_id {
6356            Ok(workspace_id) => workspace_id,
6357            Err(error) => {
6358                let result = Err(error);
6359                if harness_mcp.is_none() {
6360                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6361                }
6362                return result;
6363            }
6364        };
6365        if self.managed_worktrees.lock()
6366            .unwrap_or_else(|poisoned| poisoned.into_inner())
6367            .lease_for_workspace(&spawn_workspace_id)
6368            .is_some()
6369        {
6370            let result = Err(failure(
6371                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6372                "managed worktree targets may only be spawned through their lease request",
6373            ));
6374            if harness_mcp.is_none() {
6375                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6376            }
6377            return result;
6378        }
6379        let result = self
6380            .spawn_session_with_deadline(
6381                spawn_workspace_id,
6382                resolved.provider.clone(),
6383                resolved.mode,
6384                resolved.terminal_size,
6385                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6386                environment_profile.clone(),
6387                bundle.clone(),
6388                context.clone(),
6389                None,
6390                None,
6391                SpawnRecordPolicy::Always,
6392                Some(deadline),
6393                &required_capabilities,
6394                harness_mcp,
6395                resolved.approval_level,
6396                resolved.network_allowlist.clone(),
6397                resolved.browser_profile_id.clone(),
6398            )
6399            .await
6400            .map(|(session, _runtime_policy)| {
6401                resolved.receipt_with_materialization(
6402                    self.incarnation_id,
6403                    session,
6404                    environment_profile,
6405                    bundle,
6406                    context,
6407                )
6408            });
6409        if harness_mcp.is_none() {
6410            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6411        }
6412        result
6413    }
6414
6415    async fn spawn_from_spec_with_harness_mcp(
6416        &self,
6417        reservation_id: HarnessMcpReservationId,
6418        activation_digest: HarnessMcpActivationDigest,
6419        spec: SpawnSpec,
6420        deadline_unix_ms: u64,
6421    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6422        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6423            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6424        })?;
6425        if let Some(receipt) = registry
6426            .replay_spawn(&reservation_id, &activation_digest, &spec)
6427            .map_err(harness_mcp_failure)?
6428        {
6429            return Ok(receipt);
6430        }
6431        let resolved = self.resolve_spawn_spec(&spec)?;
6432        let prepared = registry
6433            .prepare_spawn(
6434                &reservation_id,
6435                &activation_digest,
6436                &spec,
6437                deadline_unix_ms,
6438                resolved.provider.clone(),
6439            )
6440            .map_err(harness_mcp_failure)?;
6441        self.claim_harness_mcp_spawn(
6442            &reservation_id,
6443            &activation_digest,
6444            &spec,
6445            Instant::now(),
6446        )?;
6447        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6448        let mut receipt = match spawn {
6449            Ok(receipt) => receipt,
6450            Err(error) => {
6451                let _ = registry.abort(&reservation_id, &activation_digest);
6452                return Err(error);
6453            }
6454        };
6455        let record_id = self
6456            .session_bindings
6457            .lock()
6458            .unwrap_or_else(|poisoned| poisoned.into_inner())
6459            .get(&receipt.session.session.instance_id)
6460            .and_then(|binding| binding.record_id.clone());
6461        let record_id = match record_id {
6462            Some(record_id) => record_id,
6463            None => {
6464                let rollback = self.remove_session(&receipt.session).await;
6465                let _ = registry.abort(&reservation_id, &activation_digest);
6466                if let Err(rollback_error) = rollback {
6467                    return Err(rollback_error);
6468                }
6469                return Err(failure(
6470                    NodeFailureCode::BindingMismatch,
6471                    "harness MCP spawn did not retain its exact managed record",
6472                ));
6473            }
6474        };
6475        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6476            reservation_id: reservation_id.clone(),
6477            activation_digest: activation_digest.clone(),
6478        });
6479        if let Err(error) = registry.mark_spawned(
6480            &prepared,
6481            receipt.session.clone(),
6482            record_id,
6483            receipt.clone(),
6484        ) {
6485            let rollback = self.remove_session(&receipt.session).await;
6486            let _ = registry.abort(&reservation_id, &activation_digest);
6487            if let Err(rollback_error) = rollback {
6488                return Err(rollback_error);
6489            }
6490            return Err(harness_mcp_failure(error));
6491        }
6492        Ok(receipt)
6493    }
6494
6495    async fn spawn_managed_worktree(
6496        &self,
6497        request: ManagedWorktreeSpawnRequest,
6498    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6499        self.spawn_managed_worktree_inner(request, None).await
6500    }
6501
6502    async fn spawn_managed_worktree_inner(
6503        &self,
6504        request: ManagedWorktreeSpawnRequest,
6505        durable_v2_key: Option<&SpawnIdempotencyKey>,
6506    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6507        let accepted_at = Instant::now();
6508        if durable_v2_key.is_none() {
6509            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6510                return replayed;
6511            }
6512        }
6513        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6514        if request.spawn_spec.target.worktree_id.is_some() {
6515            let result = Err(failure(
6516                NodeFailureCode::InvalidRequest,
6517                "managed worktree spawn must not select a caller-provided worktree",
6518            ));
6519            self.remember_managed_spawn_attempt(
6520                durable_v2_key,
6521                request,
6522                result.clone(),
6523                accepted_at,
6524            );
6525            return result;
6526        }
6527        let environment_profile = self.resolve_environment_profile(&resolved)?;
6528        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6529        let context = self.resolve_context(&resolved)?;
6530        let source_workspace_id = resolved.target.workspace_id.clone();
6531        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6532        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6533        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6534        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6535            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6536            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6537            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6538            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6539        };
6540        let runtime_policy = timeout(
6541            spawn_deadline_remaining(deadline)?,
6542            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6543        ).await.map_err(|_| failure(
6544            NodeFailureCode::SpawnDeadlineExceeded,
6545            "provider admission exceeded the managed spawn deadline",
6546        ))??;
6547        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6548            return Err(failure(
6549                NodeFailureCode::UnsupportedSpawnCapability,
6550                "provider runtime does not admit a required spawn capability",
6551            ));
6552        }
6553        self.ensure_binding_capacity()?;
6554        let source_root = self.workspace_root(&source_workspace_id)?;
6555        profile.validate_for_workspace(&source_root)
6556            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6557        let base_timeout_ms = u64::try_from(
6558            spawn_deadline_remaining(deadline)?.as_millis(),
6559        ).unwrap_or(u64::MAX).max(1);
6560        let base_commit = resolve_base_commit_with_timeout(
6561            &source_root,
6562            profile.base(),
6563            base_timeout_ms,
6564        ).await.map_err(|error| {
6565            if Instant::now() >= deadline || error.message.contains("timed out") {
6566                failure(
6567                    NodeFailureCode::SpawnDeadlineExceeded,
6568                    "managed worktree base resolution exceeded the spawn deadline",
6569                )
6570            } else {
6571                managed_git_worktree_failure(error)
6572            }
6573        })?;
6574
6575        let lease = {
6576            let _transaction = self.state_transaction.lock()
6577                .unwrap_or_else(|poisoned| poisoned.into_inner());
6578            let previous = self.managed_worktrees.lock()
6579                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6580            let lease = self.managed_worktrees.lock()
6581                .unwrap_or_else(|poisoned| poisoned.into_inner())
6582                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6583                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6584            let previous_replay = match durable_v2_key
6585                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6586                .transpose()
6587            {
6588                Ok(previous) => previous,
6589                Err(error) => {
6590                    *self.managed_worktrees.lock()
6591                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6592                    return Err(error);
6593                }
6594            };
6595            if let Err(error) = self.persist_state_locked() {
6596                *self.managed_worktrees.lock()
6597                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6598                if let (Some(key), Some(previous_replay)) =
6599                    (durable_v2_key, previous_replay)
6600                {
6601                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6602                }
6603                return Err(persistence_failure(error));
6604            }
6605            lease
6606        };
6607        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6608        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6609            self.managed_worktrees.lock()
6610                .unwrap_or_else(|poisoned| poisoned.into_inner())
6611                .remove_unmutated(&lease.lease_id);
6612            if self.persist_state().is_err() {
6613                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6614            }
6615            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6616            self.remember_managed_spawn_attempt(
6617                durable_v2_key,
6618                request,
6619                result.clone(),
6620                accepted_at,
6621            );
6622            return result;
6623        }
6624        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6625            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6626            Err(error) => {
6627                self.reconcile_failed_allocation(&lease, &source_root).await;
6628                let result = Err(error);
6629                self.remember_managed_spawn_attempt(
6630                    durable_v2_key,
6631                    request,
6632                    result.clone(),
6633                    accepted_at,
6634                );
6635                return result;
6636            }
6637        };
6638        let created = match create_git_worktree_with_timeout(
6639                &source_root,
6640                &lease.target_root,
6641                &lease.branch,
6642                Some(&base_commit),
6643                mutation_timeout_ms,
6644            ).await {
6645            Ok(created) => created,
6646            Err(error) => {
6647                self.reconcile_failed_allocation(&lease, &source_root).await;
6648                let result = Err(if Instant::now() >= deadline
6649                    || error.message.contains("timed out")
6650                    || error.message.contains("deadline")
6651                {
6652                    failure(
6653                        NodeFailureCode::SpawnDeadlineExceeded,
6654                        "managed worktree creation exceeded the spawn deadline",
6655                    )
6656                } else {
6657                    managed_git_worktree_failure(error)
6658                });
6659                self.remember_managed_spawn_attempt(
6660                    durable_v2_key,
6661                    request,
6662                    result.clone(),
6663                    accepted_at,
6664                );
6665                return result;
6666            }
6667        };
6668        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6669            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6670            let result = Err(failure(
6671                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6672                "created worktree identity did not match its durable lease",
6673            ));
6674            self.remember_managed_spawn_attempt(
6675                durable_v2_key,
6676                request,
6677                result.clone(),
6678                accepted_at,
6679            );
6680            return result;
6681        }
6682        {
6683            let mut registry = self.managed_worktrees.lock()
6684                .unwrap_or_else(|poisoned| poisoned.into_inner());
6685            let current = registry.get_mut(&lease.lease_id)
6686                .expect("allocated managed worktree lease remains present");
6687            current.expected_head = Some(created.head.clone());
6688            current.state = ManagedWorktreeLeaseState::Ready;
6689            current.updated_at_unix_ms = unix_time_ms();
6690        }
6691        if let Err(error) = self.persist_state() {
6692            self.mark_managed_recovery_required(
6693                &lease.lease_id,
6694                ManagedWorktreeCleanupFailure::Backend,
6695            );
6696            let result = Err(persistence_failure(error));
6697            self.remember_managed_spawn_attempt(
6698                durable_v2_key,
6699                request,
6700                result.clone(),
6701                accepted_at,
6702            );
6703            return result;
6704        }
6705        let ready = self.managed_worktrees.lock()
6706            .unwrap_or_else(|poisoned| poisoned.into_inner())
6707            .get(&lease.lease_id)
6708            .expect("ready managed worktree lease remains present")
6709            .snapshot();
6710        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6711        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6712            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6713            let result = Err(error);
6714            self.remember_managed_spawn_attempt(
6715                durable_v2_key,
6716                request,
6717                result.clone(),
6718                accepted_at,
6719            );
6720            return result;
6721        }
6722        let spawn = self.spawn_session_with_deadline(
6723            lease.workspace_id.clone(),
6724            resolved.provider.clone(),
6725            resolved.mode,
6726            resolved.terminal_size,
6727            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6728            environment_profile.clone(),
6729            bundle.clone(),
6730            context.clone(),
6731            Some(lease.lease_id.clone()),
6732            Some(runtime_policy),
6733            SpawnRecordPolicy::Always,
6734            Some(deadline),
6735            &required_capabilities,
6736            None,
6737            resolved.approval_level,
6738            resolved.network_allowlist.clone(),
6739            resolved.browser_profile_id.clone(),
6740        ).await;
6741        let (session, effective_runtime_policy) = match spawn {
6742            Ok(spawned) => spawned,
6743            Err(error) => {
6744                if self
6745                    .cleanup_managed_worktree(&lease.lease_id, true)
6746                    .await
6747                    .is_err()
6748                {
6749                    self.mark_managed_recovery_required(
6750                        &lease.lease_id,
6751                        ManagedWorktreeCleanupFailure::Backend,
6752                    );
6753                }
6754                let result = Err(error);
6755                self.remember_managed_spawn_attempt(
6756                    durable_v2_key,
6757                    request,
6758                    result.clone(),
6759                    accepted_at,
6760                );
6761                return result;
6762            }
6763        };
6764        let (record_id, raw_inventory_record_id) = {
6765            let mut bindings = self.session_bindings.lock()
6766                .unwrap_or_else(|poisoned| poisoned.into_inner());
6767            let binding = bindings.get_mut(&session.session.instance_id)
6768                .expect("successful spawn retains its session binding");
6769            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6770            managed_spawn_record_ownership(binding, effective_runtime_policy)
6771        };
6772        let bound = self.managed_worktrees.lock()
6773            .unwrap_or_else(|poisoned| poisoned.into_inner())
6774            .bind_session(
6775                &lease.lease_id,
6776                ManagedWorktreeSessionHolder {
6777                    incarnation_id: self.incarnation_id,
6778                    instance_id: session.session.instance_id,
6779                    generation: session.session.generation,
6780                },
6781                record_id,
6782                unix_time_ms(),
6783            )
6784            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6785        let snapshot = match bound {
6786            Ok(snapshot) => snapshot,
6787            Err(error) => {
6788                if raw_inventory_record_id
6789                    .as_ref()
6790                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
6791                {
6792                    self.mark_managed_recovery_required(
6793                        &lease.lease_id,
6794                        ManagedWorktreeCleanupFailure::Backend,
6795                    );
6796                }
6797                let _ = self.remove_session(&session).await;
6798                self.mark_managed_recovery_required(
6799                    &lease.lease_id,
6800                    ManagedWorktreeCleanupFailure::Backend,
6801                );
6802                let result = Err(error);
6803                self.remember_managed_spawn_attempt(
6804                    durable_v2_key,
6805                    request,
6806                    result.clone(),
6807                    accepted_at,
6808                );
6809                return result;
6810            }
6811        };
6812        if let Err(error) = self.persist_state() {
6813            if raw_inventory_record_id
6814                .as_ref()
6815                .is_some_and(|record_id| self.discard_record(record_id).is_err())
6816            {
6817                self.mark_managed_recovery_required(
6818                    &lease.lease_id,
6819                    ManagedWorktreeCleanupFailure::Backend,
6820                );
6821            }
6822            let _ = self.remove_session(&session).await;
6823            if self
6824                .cleanup_managed_worktree(&lease.lease_id, true)
6825                .await
6826                .is_err()
6827            {
6828                self.mark_managed_recovery_required(
6829                    &lease.lease_id,
6830                    ManagedWorktreeCleanupFailure::Backend,
6831                );
6832            }
6833            let result = Err(persistence_failure(error));
6834            self.remember_managed_spawn_attempt(
6835                durable_v2_key,
6836                request,
6837                result.clone(),
6838                accepted_at,
6839            );
6840            return result;
6841        }
6842        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6843        let receipt = managed_spawn_receipt(
6844            &resolved,
6845            self.incarnation_id,
6846            session,
6847            snapshot,
6848            environment_profile,
6849            bundle,
6850            context,
6851        );
6852        let result = Ok(receipt);
6853        self.remember_managed_spawn_attempt(
6854            durable_v2_key,
6855            request,
6856            result.clone(),
6857            accepted_at,
6858        );
6859        result
6860    }
6861
6862    async fn spawn_managed_worktree_v2(
6863        &self,
6864        request: ManagedWorktreeSpawnRequestV2,
6865    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6866        if request.spawn_spec.target.node_id != self.node_id {
6867            return Err(failure(
6868                NodeFailureCode::SpawnTargetMismatch,
6869                "spawn target does not match this node",
6870            ));
6871        }
6872        if request.spawn_spec.target.worktree_id.is_some() {
6873            return Err(failure(
6874                NodeFailureCode::InvalidRequest,
6875                "managed worktree spawn must not select a caller-provided worktree",
6876            ));
6877        }
6878        let request_digest = managed_spawn_request_digest_v2(&request)?;
6879        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6880        match replay {
6881            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6882            DurableManagedSpawnReplayDecision::PendingLinked => {
6883                return Err(failure(
6884                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6885                    "managed worktree V2 reservation has an incomplete durable allocation",
6886                ));
6887            }
6888            DurableManagedSpawnReplayDecision::Reserved => {}
6889        }
6890        let validated = (|| {
6891            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6892            let profile = self.managed_profile(
6893                &resolved.target.workspace_id,
6894                &request.worktree_profile_id,
6895            )?;
6896            if profile.revision() != &request.expected_profile_revision {
6897                return Err(failure(
6898                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6899                    "managed worktree profile revision does not match the loaded profile",
6900                ));
6901            }
6902            Ok(())
6903        })();
6904        match validated {
6905            Ok(()) => {}
6906            Err(error) => {
6907                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6908                return Err(error);
6909            }
6910        }
6911        let legacy = ManagedWorktreeSpawnRequest {
6912            spawn_spec: request.spawn_spec.clone(),
6913            worktree_profile_id: request.worktree_profile_id.clone(),
6914        };
6915        match self
6916            .spawn_managed_worktree_inner(
6917                legacy,
6918                Some(&request.spawn_spec.idempotency_key),
6919            )
6920            .await
6921        {
6922            Ok(receipt) => {
6923                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6924                Ok(receipt)
6925            }
6926            Err(error) => {
6927                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6928                Err(error)
6929            }
6930        }
6931    }
6932
6933    fn managed_profile(
6934        &self,
6935        workspace_id: &WorkspaceId,
6936        profile_id: &WorktreeProfileId,
6937    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6938        if self.worktree_service_modes.get(workspace_id).copied()
6939            != Some(WorktreeServiceMode::Managed)
6940        {
6941            return Err(failure(
6942                NodeFailureCode::UnsupportedSpawnCapability,
6943                "workspace does not enable managed worktrees",
6944            ));
6945        }
6946        self.managed_worktree_profiles.get(workspace_id)
6947            .and_then(|profiles| profiles.get(profile_id))
6948            .cloned()
6949            .ok_or_else(|| failure(
6950                NodeFailureCode::InvalidRequest,
6951                "managed worktree profile is unavailable for this workspace",
6952            ))
6953    }
6954
6955    async fn reconcile_failed_allocation(
6956        &self,
6957        lease: &ManagedWorktreeLeaseRecord,
6958        source_root: &str,
6959    ) {
6960        match list_git_worktrees(source_root).await {
6961            Ok(worktrees) => {
6962                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6963                    None => {
6964                        match std::fs::symlink_metadata(&lease.target_root) {
6965                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
6966                                self.managed_worktrees.lock()
6967                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6968                                    .remove_unmutated(&lease.lease_id);
6969                                let _ = self.persist_state();
6970                            }
6971                            _ => self.mark_managed_recovery_required(
6972                                &lease.lease_id,
6973                                ManagedWorktreeCleanupFailure::OwnershipConflict,
6974                            ),
6975                        }
6976                    }
6977                    Some(item) if exact_owned_worktree(
6978                        lease,
6979                        &item.path,
6980                        item.branch.as_deref(),
6981                        &item.head,
6982                    ) => {
6983                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6984                    }
6985                    Some(_) => self.mark_managed_recovery_required(
6986                        &lease.lease_id,
6987                        ManagedWorktreeCleanupFailure::OwnershipConflict,
6988                    ),
6989                }
6990            }
6991            Err(_) => self.mark_managed_recovery_required(
6992                &lease.lease_id,
6993                ManagedWorktreeCleanupFailure::Backend,
6994            ),
6995        }
6996    }
6997
6998    async fn reconcile_managed_worktrees(&self) {
6999        let leases = self.managed_worktrees.lock()
7000            .unwrap_or_else(|poisoned| poisoned.into_inner())
7001            .active_records()
7002            .cloned()
7003            .collect::<Vec<_>>();
7004        for lease in leases {
7005            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7006                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7007                _ => {
7008                    self.mark_managed_recovery_required(
7009                        &lease.lease_id,
7010                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7011                    );
7012                    continue;
7013                }
7014            };
7015            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7016                self.mark_managed_recovery_required(
7017                    &lease.lease_id,
7018                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7019                );
7020                continue;
7021            }
7022            let source_root = match self.workspace_root(&lease.source_workspace_id) {
7023                Ok(root) => root,
7024                Err(_) => {
7025                    self.mark_managed_recovery_required(
7026                        &lease.lease_id,
7027                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7028                    );
7029                    continue;
7030                }
7031            };
7032            let worktrees = match list_git_worktrees(&source_root).await {
7033                Ok(worktrees) => worktrees,
7034                Err(_) => {
7035                    self.mark_managed_recovery_required(
7036                        &lease.lease_id,
7037                        ManagedWorktreeCleanupFailure::Backend,
7038                    );
7039                    continue;
7040                }
7041            };
7042            let listed = worktrees.iter()
7043                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7044            let Some(listed) = listed else {
7045                match std::fs::symlink_metadata(&lease.target_root) {
7046                    Err(error) if error.kind() == io::ErrorKind::NotFound
7047                        && lease.state == ManagedWorktreeLeaseState::Allocating
7048                        && !lease.has_holders() => {
7049                            let removed = self.managed_worktrees.lock()
7050                                .unwrap_or_else(|poisoned| poisoned.into_inner())
7051                                .tombstone(&lease.lease_id, unix_time_ms());
7052                            if self.persist_state().is_ok() && removed.is_some() {
7053                                self.publish(NodeEvent::ManagedWorktreeRemoved {
7054                                    lease_id: lease.lease_id.clone(),
7055                                });
7056                            }
7057                        }
7058                    _ => self.mark_managed_recovery_required(
7059                        &lease.lease_id,
7060                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7061                    ),
7062                }
7063                continue;
7064            };
7065            if listed.is_main || listed.is_bare || !exact_owned_worktree(
7066                &lease,
7067                &listed.path,
7068                listed.branch.as_deref(),
7069                &listed.head,
7070            ) {
7071                self.mark_managed_recovery_required(
7072                    &lease.lease_id,
7073                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7074                );
7075                continue;
7076            }
7077            if !self.workspaces.read()
7078                .unwrap_or_else(|poisoned| poisoned.into_inner())
7079                .contains_key(&lease.workspace_id)
7080            {
7081                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7082                    self.mark_managed_recovery_required(
7083                        &lease.lease_id,
7084                        ManagedWorktreeCleanupFailure::Backend,
7085                    );
7086                    continue;
7087                }
7088            }
7089            let snapshot = {
7090                let mut registry = self.managed_worktrees.lock()
7091                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7092                let current = registry.get_mut(&lease.lease_id)
7093                    .expect("reconciled managed lease remains present");
7094                current.expected_head = Some(listed.head.clone());
7095                current.cleanup_failure = None;
7096                current.state = if current.has_holders() {
7097                    ManagedWorktreeLeaseState::InUse
7098                } else if current.retention == ManagedWorktreeRetention::Retain {
7099                    ManagedWorktreeLeaseState::Retained
7100                } else {
7101                    ManagedWorktreeLeaseState::Ready
7102                };
7103                current.updated_at_unix_ms = unix_time_ms();
7104                current.snapshot()
7105            };
7106            if self.persist_state().is_err() {
7107                self.mark_managed_recovery_required(
7108                    &lease.lease_id,
7109                    ManagedWorktreeCleanupFailure::Backend,
7110                );
7111                continue;
7112            }
7113            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7114            if snapshot.state == ManagedWorktreeLeaseState::Ready {
7115                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7116            }
7117        }
7118    }
7119
7120    /// Crash-recovery startup sweep: durably capture the pack for every
7121    /// `Live` managed record whose most recent session has not yet produced
7122    /// one (recovers a session whose `Exited{0}` event was observed but the
7123    /// Node crashed before the export finished). The reactive fast path for
7124    /// a single just-exited record does not go through this — see
7125    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
7126    /// it can capture the exact `SessionAddress` before `publish_control`
7127    /// clears it, rather than re-deriving it from (by then likely already
7128    /// `Dormant`) record state the way a rescan naturally would.
7129    ///
7130    /// Returns as soon as every eligible record's export has been *spawned*,
7131    /// not completed — each one runs on its own detached task (see
7132    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
7133    /// `NativeRuntime::tick()` has not even started its own loop yet at the
7134    /// point this is called from `NodeServer::run()`, so nothing could ever
7135    /// drain the history round trip if this awaited it inline here either).
7136    /// Best-effort throughout: never panics, no unwrap/expect; every failure
7137    /// is a silent skip for THIS record — an unusable source or a racing
7138    /// record is not surfaced to any caller.
7139    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7140        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7141            .session_records
7142            .lock()
7143            .unwrap_or_else(|poisoned| poisoned.into_inner())
7144            .records
7145            .values()
7146            .filter(|record| {
7147                record.state == ManagedSessionState::Live
7148                    && record.exported_context.is_none()
7149                    && record.provider_session.is_some()
7150            })
7151            .filter_map(|record| {
7152                record
7153                    .active_session
7154                    .clone()
7155                    .map(|session| (record.record_id.clone(), session))
7156            })
7157            .collect();
7158        for (record_id, session) in candidates {
7159            let shared = Arc::clone(self);
7160            tokio::spawn(async move {
7161                shared
7162                    .reconcile_context_pack_export_for_record(&record_id, session)
7163                    .await;
7164            });
7165        }
7166    }
7167
7168    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
7169    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
7170    /// round trip this performs (`discover_history`/`load_history`, via
7171    /// `export_context_pack_for_session_record`) can only ever settle once
7172    /// `NativeRuntime::tick()` runs again to drain the authority worker's
7173    /// completion — and `tick()` is exclusively owned by, and only ever
7174    /// called from, that same drive-loop task. Awaiting this inline there
7175    /// would starve `tick()` and this call could never observe its own
7176    /// command complete.
7177    ///
7178    /// Because this now genuinely races the drive loop's own
7179    /// `publish_control` (which downgrades this exact record from `Live` to
7180    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
7181    /// triggered this reconcile), the record legitimately may already be
7182    /// `Dormant` by the time this reads it, or may transition mid-flight —
7183    /// `allow_clean_detachment: true` on the downstream export call is what
7184    /// tolerates exactly that transition (and only that transition; see
7185    /// `session_record_export_target_matches`).
7186    async fn reconcile_context_pack_export_for_record(
7187        &self,
7188        record_id: &SessionRecordId,
7189        session: SessionAddress,
7190    ) {
7191        let Ok(record) = self.record(record_id) else {
7192            return;
7193        };
7194        if !matches!(
7195            record.state,
7196            ManagedSessionState::Live | ManagedSessionState::Dormant
7197        ) || record.exported_context.is_some()
7198            || record.provider_session.is_none()
7199        {
7200            return;
7201        }
7202        // Pack bytes are already durably committed inside this call, via
7203        // commit_context_pack_for_session_record's own durable
7204        // write-through (context_pack_store) — no separate step here.
7205        // Unclean exits (non-zero/forced/no loadable history) are already
7206        // rejected by context_pack_source_status_is_usable inside
7207        // materialize_context_pack: this simply returns Err and is skipped.
7208        let Ok(receipt) = self
7209            .export_context_pack_for_session_record(&record.record_id, &session, true)
7210            .await
7211        else {
7212            return;
7213        };
7214        self.set_exported_context(&record.record_id, receipt);
7215    }
7216
7217    /// Records the record's most recent clean-exit pack. No-op (not an
7218    /// error) if the record disappeared, or if it already carries an
7219    /// `exported_context` — first-write-wins, matching the harness-side
7220    /// idempotent reconciliation that consumes this field.
7221    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7222        let transaction = self
7223            .state_transaction
7224            .lock()
7225            .unwrap_or_else(|poisoned| poisoned.into_inner());
7226        let (previous, updated) = {
7227            let mut records = self
7228                .session_records
7229                .lock()
7230                .unwrap_or_else(|poisoned| poisoned.into_inner());
7231            let Some(record) = records.records.get_mut(record_id) else {
7232                return;
7233            };
7234            if record.exported_context.is_some() {
7235                return;
7236            }
7237            let previous = record.clone();
7238            record.exported_context = Some(receipt);
7239            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7240            (previous, record.clone())
7241        };
7242        if self.persist_state_locked().is_err() {
7243            self.session_records
7244                .lock()
7245                .unwrap_or_else(|poisoned| poisoned.into_inner())
7246                .records
7247                .insert(record_id.clone(), previous);
7248            return;
7249        }
7250        drop(transaction);
7251        self.publish_record(updated);
7252    }
7253
7254    /// Resolves the managed record and its exact `SessionAddress` currently
7255    /// bound to `instance_id` at exactly `generation`, used by the reactive
7256    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
7257    /// record without a full rescan. Mirrors the lookup already inlined in
7258    /// `publish_control`. Captured synchronously, before `publish_control`
7259    /// for the same event clears the record's own `active_session` — the
7260    /// caller passes the resolved address through to a detached
7261    /// reconciliation task rather than re-deriving it from the record later,
7262    /// after that race has necessarily already happened.
7263    fn managed_record_export_target_for_instance(
7264        &self,
7265        instance_id: AgentInstanceId,
7266        generation: SessionGeneration,
7267    ) -> Option<(SessionRecordId, SessionAddress)> {
7268        let bindings = self
7269            .session_bindings
7270            .lock()
7271            .unwrap_or_else(|poisoned| poisoned.into_inner());
7272        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7273        let record_id = binding.record_id.clone()?;
7274        Some((
7275            record_id,
7276            SessionAddress {
7277                workspace_id: binding.workspace_id.clone(),
7278                session: SessionKey { instance_id, generation },
7279            },
7280        ))
7281    }
7282
7283    fn mark_managed_recovery_required(
7284        &self,
7285        lease_id: &ManagedWorktreeLeaseId,
7286        failure_kind: ManagedWorktreeCleanupFailure,
7287    ) {
7288        let snapshot = {
7289            let mut registry = self.managed_worktrees.lock()
7290                .unwrap_or_else(|poisoned| poisoned.into_inner());
7291            let Some(lease) = registry.get_mut(lease_id) else { return };
7292            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7293            lease.cleanup_failure = Some(failure_kind);
7294            lease.updated_at_unix_ms = unix_time_ms();
7295            lease.snapshot()
7296        };
7297        let _ = self.persist_state();
7298        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7299    }
7300
7301    fn set_persistence_error(&self, error: Option<String>) {
7302        *self
7303            .persistence_error
7304            .write()
7305            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7306            .map(|message| session_registry::sanitized_persistence_summary(&message));
7307    }
7308
7309    fn persist_state(&self) -> io::Result<()> {
7310        let _transaction = self
7311            .state_transaction
7312            .lock()
7313            .unwrap_or_else(|poisoned| poisoned.into_inner());
7314        self.persist_state_locked()
7315    }
7316
7317    fn persist_state_locked(&self) -> io::Result<()> {
7318        let workspaces = self
7319            .workspaces
7320            .read()
7321            .unwrap_or_else(|poisoned| poisoned.into_inner())
7322            .clone();
7323        let records = self
7324            .session_records
7325            .lock()
7326            .unwrap_or_else(|poisoned| poisoned.into_inner())
7327            .records
7328            .values()
7329            .cloned()
7330            .collect::<Vec<_>>();
7331        let mut managed = self.managed_worktrees
7332            .lock()
7333            .unwrap_or_else(|poisoned| poisoned.into_inner());
7334        managed.reattach_record_holders(
7335            &managed_worktree_record_holders(&records),
7336            unix_time_ms(),
7337        );
7338        let materializations = self
7339            .materializations
7340            .lock()
7341            .unwrap_or_else(|poisoned| poisoned.into_inner())
7342            .values()
7343            .cloned()
7344            .collect::<Vec<_>>();
7345        let managed_spawn_replays = self.managed_spawn_replays
7346            .lock()
7347            .unwrap_or_else(|poisoned| poisoned.into_inner())
7348            .values()
7349            .cloned()
7350            .collect::<Vec<_>>();
7351        let result = session_registry::save_v10(
7352            self.state_path.as_deref(),
7353            &self.node_id,
7354            &workspaces,
7355            &records,
7356            &managed.records(),
7357            &managed.tombstones(),
7358            &materializations,
7359            &managed_spawn_replays,
7360        );
7361        match &result {
7362            Ok(warning) => self.set_persistence_error(warning.clone()),
7363            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7364        }
7365        result.map(|_| ())
7366    }
7367
7368    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7369        let _mutation_guard = self.mutation_gate.lock().await;
7370        self.begin_shutdown_locked().await
7371    }
7372
7373    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7374        if self.shutdown.swap(true, Ordering::AcqRel) {
7375            return Ok(());
7376        }
7377        SessionLifecycleHook::on_shutdown(self);
7378        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7379        let mut first_error = None;
7380        for session in &self.handle.snapshot().sessions {
7381            let remaining = deadline.saturating_duration_since(Instant::now());
7382            if remaining.is_zero() {
7383                first_error.get_or_insert_with(|| {
7384                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7385                });
7386                break;
7387            }
7388            if let Err(error) = self
7389                .dispatch_bounded(
7390                    ControlCommand::Stop {
7391                        instance_id: session.instance_id,
7392                        force: true,
7393                    },
7394                    remaining,
7395                )
7396                .await
7397            {
7398                first_error.get_or_insert(error);
7399            }
7400        }
7401        self.shutdown_notify.notify_waiters();
7402        match first_error {
7403            Some(error) => Err(error),
7404            None => Ok(()),
7405        }
7406    }
7407
7408    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7409        let bindings = self
7410            .session_bindings
7411            .lock()
7412            .unwrap_or_else(|poisoned| poisoned.into_inner());
7413        if bindings.len() >= CONTROL_SESSIONS_MAX {
7414            Err(failure(
7415                NodeFailureCode::BackendBusy,
7416                "node session-binding capacity is full; remove an existing session first",
7417            ))
7418        } else {
7419            Ok(())
7420        }
7421    }
7422
7423    /// One-owner workspace/env collision guard: a live session on
7424    /// `workspace_id` with a different `environment_profile` (option
7425    /// equality on the resolved receipt, including Some vs None) blocks
7426    /// another spawn on that same cwd. Same profile may still share the
7427    /// workspace; distinct profiles need distinct workspace ids (managed
7428    /// worktree).
7429    fn ensure_workspace_environment_profile_compatible(
7430        &self,
7431        workspace_id: &WorkspaceId,
7432        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7433    ) -> Result<(), NodeFailure> {
7434        let bindings = self
7435            .session_bindings
7436            .lock()
7437            .unwrap_or_else(|poisoned| poisoned.into_inner());
7438        for binding in bindings.values() {
7439            if &binding.workspace_id != workspace_id {
7440                continue;
7441            }
7442            if binding.environment_profile.as_ref() != environment_profile {
7443                return Err(failure(
7444                    NodeFailureCode::WorkspaceBusy,
7445                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7446                ));
7447            }
7448        }
7449        Ok(())
7450    }
7451
7452    #[cfg(test)]
7453    fn bind_session(
7454        &self,
7455        instance_id: AgentInstanceId,
7456        workspace_id: WorkspaceId,
7457        generation: SessionGeneration,
7458    ) {
7459        self.bind_session_with_policy(
7460            &SessionAddress {
7461                workspace_id,
7462                session: SessionKey { instance_id, generation },
7463            },
7464            ProviderRuntimePolicy::raw_pty(),
7465            None,
7466        );
7467    }
7468
7469    #[cfg(test)]
7470    fn bind_session_with_policy(
7471        &self,
7472        address: &SessionAddress,
7473        runtime_policy: ProviderRuntimePolicy,
7474        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7475    ) {
7476        self.bind_session_with_materialization(
7477            address,
7478            runtime_policy,
7479            environment_profile,
7480            None,
7481            None,
7482            None,
7483        );
7484    }
7485
7486    fn bind_session_with_materialization(
7487        &self,
7488        address: &SessionAddress,
7489        runtime_policy: ProviderRuntimePolicy,
7490        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7491        bundle: Option<ResolvedBundleReceipt>,
7492        context: Option<ResolvedContextPackReceipt>,
7493        materialization_id: Option<MaterializationId>,
7494    ) {
7495        let mut bindings = self
7496            .session_bindings
7497            .lock()
7498            .unwrap_or_else(|poisoned| poisoned.into_inner());
7499        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7500        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7501        bindings.insert(address.session.instance_id, SessionBinding {
7502            workspace_id: address.workspace_id.clone(),
7503            generation: address.session.generation,
7504            runtime_policy,
7505            pending_resume: None,
7506            record_id: None,
7507            managed_worktree_lease_id: None,
7508            environment_profile,
7509            bundle,
7510            context,
7511            materialization_id,
7512        });
7513    }
7514
7515    #[cfg(test)]
7516    fn bind_managed_session(
7517        &self,
7518        address: &SessionAddress,
7519        record_id: SessionRecordId,
7520        runtime_policy: ProviderRuntimePolicy,
7521        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7522    ) {
7523        self.bind_managed_session_with_materialization(
7524            address,
7525            record_id,
7526            runtime_policy,
7527            environment_profile,
7528            None,
7529            None,
7530            None,
7531        );
7532    }
7533
7534    fn bind_managed_session_with_materialization(
7535        &self,
7536        address: &SessionAddress,
7537        record_id: SessionRecordId,
7538        runtime_policy: ProviderRuntimePolicy,
7539        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7540        bundle: Option<ResolvedBundleReceipt>,
7541        context: Option<ResolvedContextPackReceipt>,
7542        materialization_id: Option<MaterializationId>,
7543    ) {
7544        let mut bindings = self
7545            .session_bindings
7546            .lock()
7547            .unwrap_or_else(|poisoned| poisoned.into_inner());
7548        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7549        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7550        bindings.insert(
7551            address.session.instance_id,
7552            SessionBinding {
7553                workspace_id: address.workspace_id.clone(),
7554                generation: address.session.generation,
7555                runtime_policy,
7556                pending_resume: None,
7557                record_id: Some(record_id),
7558                managed_worktree_lease_id: None,
7559                environment_profile,
7560                bundle,
7561                context,
7562                materialization_id,
7563            },
7564        );
7565    }
7566
7567    #[cfg(test)]
7568    fn bind_spawn_session(
7569        &self,
7570        address: &SessionAddress,
7571        provider: AgentId,
7572        mode: SessionMode,
7573        runtime_policy: ProviderRuntimePolicy,
7574        record_policy: SpawnRecordPolicy,
7575        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7576    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7577        self.bind_spawn_session_with_materialization(
7578            address,
7579            provider,
7580            mode,
7581            runtime_policy,
7582            record_policy,
7583            environment_profile,
7584            None,
7585            None,
7586            None,
7587        )
7588    }
7589
7590    fn bind_spawn_session_with_materialization(
7591        &self,
7592        address: &SessionAddress,
7593        provider: AgentId,
7594        mode: SessionMode,
7595        runtime_policy: ProviderRuntimePolicy,
7596        record_policy: SpawnRecordPolicy,
7597        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7598        bundle: Option<ResolvedBundleReceipt>,
7599        context: Option<ResolvedContextPackReceipt>,
7600        materialization_id: Option<MaterializationId>,
7601    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7602        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7603            && !runtime_policy.provider_session_identity
7604        {
7605            self.bind_session_with_materialization(
7606                address,
7607                runtime_policy,
7608                environment_profile,
7609                bundle,
7610                context,
7611                materialization_id,
7612            );
7613            return Ok(None);
7614        }
7615        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7616        let record = self.new_record(
7617            address,
7618            provider,
7619            mode,
7620            active_record_state(runtime_policy.provider_session_identity, false),
7621            None,
7622            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7623            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7624            record_owns_lifecycle.then(|| context.clone()).flatten(),
7625        )?;
7626        let record_id = record.record_id.clone();
7627        let transaction = self
7628            .state_transaction
7629            .lock()
7630            .unwrap_or_else(|poisoned| poisoned.into_inner());
7631        self.insert_record(record.clone())?;
7632        let previous_materialization = if record_owns_lifecycle {
7633            if let Some(materialization_id) = materialization_id.as_ref() {
7634                let mut materializations = self
7635                    .materializations
7636                    .lock()
7637                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7638                let Some(ownership) = materializations.get_mut(materialization_id) else {
7639                    drop(materializations);
7640                    self.remove_record_memory(&record_id);
7641                    return Err(failure(
7642                        NodeFailureCode::BackendOperationFailed,
7643                        "session environment ownership disappeared before binding",
7644                    ));
7645                };
7646                let previous = ownership.clone();
7647                if ownership
7648                    .transfer_to_record(record_id.clone(), unix_time_ms())
7649                    .is_err()
7650                {
7651                    drop(materializations);
7652                    self.remove_record_memory(&record_id);
7653                    return Err(failure(
7654                        NodeFailureCode::BackendOperationFailed,
7655                        "session environment ownership transfer failed",
7656                    ));
7657                }
7658                Some(previous)
7659            } else {
7660                None
7661            }
7662        } else {
7663            None
7664        };
7665        self.bind_managed_session_with_materialization(
7666            address,
7667            record_id.clone(),
7668            runtime_policy,
7669            environment_profile,
7670            bundle,
7671            context,
7672            materialization_id.clone(),
7673        );
7674        if let Err(error) = self.persist_state_locked() {
7675            self.remove_binding(address);
7676            self.remove_record_memory(&record_id);
7677            if let (Some(materialization_id), Some(previous)) =
7678                (materialization_id.as_ref(), previous_materialization)
7679            {
7680                self.materializations
7681                    .lock()
7682                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7683                    .insert(materialization_id.clone(), previous);
7684            }
7685            return Err(persistence_failure(error));
7686        }
7687        drop(transaction);
7688        self.publish_record(record);
7689        Ok(Some(record_id))
7690    }
7691
7692    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7693        for _ in 0..8 {
7694            let bytes = random_nonce().map_err(|error| {
7695                failure(
7696                    NodeFailureCode::BackendOperationFailed,
7697                    &format!("session record identity generation failed: {error}"),
7698                )
7699            })?;
7700            let mut value = String::with_capacity(27);
7701            value.push_str("sr-");
7702            for byte in &bytes[..12] {
7703                use std::fmt::Write as _;
7704                let _ = write!(&mut value, "{byte:02x}");
7705            }
7706            let record_id = SessionRecordId::new(value).map_err(|error| {
7707                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7708            })?;
7709            let records = self
7710                .session_records
7711                .lock()
7712                .unwrap_or_else(|poisoned| poisoned.into_inner());
7713            if !records.records.contains_key(&record_id) {
7714                return Ok(record_id);
7715            }
7716        }
7717        Err(failure(
7718            NodeFailureCode::BackendBusy,
7719            "could not allocate a unique session record ID",
7720        ))
7721    }
7722
7723    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7724        for _ in 0..8 {
7725            let bytes = random_nonce().map_err(|error| {
7726                failure(
7727                    NodeFailureCode::BackendOperationFailed,
7728                    &format!("task identity generation failed: {error}"),
7729                )
7730            })?;
7731            let mut nonce = [0_u8; 12];
7732            nonce.copy_from_slice(&bytes[..12]);
7733            let task_id = TaskId::from_nonce(nonce);
7734            let records = self
7735                .session_records
7736                .lock()
7737                .unwrap_or_else(|poisoned| poisoned.into_inner());
7738            if !records.records.values().any(|record| {
7739                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7740                    == Some(&task_id)
7741            }) {
7742                return Ok(task_id);
7743            }
7744        }
7745        Err(failure(
7746            NodeFailureCode::BackendBusy,
7747            "could not allocate a unique task ID",
7748        ))
7749    }
7750
7751    fn default_record_name(&self, provider: &AgentId) -> String {
7752        let records = self
7753            .session_records
7754            .lock()
7755            .unwrap_or_else(|poisoned| poisoned.into_inner());
7756        let ordinal = records
7757            .records
7758            .values()
7759            .filter(|record| &record.provider == provider)
7760            .count()
7761            .saturating_add(1);
7762        format!("{} #{ordinal}", provider.as_str())
7763    }
7764
7765    fn new_record(
7766        &self,
7767        address: &SessionAddress,
7768        provider: AgentId,
7769        mode: SessionMode,
7770        state: ManagedSessionState,
7771        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7772        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7773        bundle: Option<ResolvedBundleReceipt>,
7774        context: Option<ResolvedContextPackReceipt>,
7775    ) -> Result<ManagedSessionRecord, NodeFailure> {
7776        let canonical_root = self.workspace_root(&address.workspace_id)?;
7777        let now = unix_time_ms();
7778        Ok(ManagedSessionRecord {
7779            record_id: self.allocate_record_id()?,
7780            display_name: self.default_record_name(&provider),
7781            provider,
7782            mode,
7783            state,
7784            workspace_id: address.workspace_id.clone(),
7785            canonical_root: opaque_windows_path(canonical_root),
7786            provider_session,
7787            active_session: Some(address.clone()),
7788            environment_profile,
7789            bundle,
7790            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7791            context,
7792            exported_context: None,
7793            task_binding: None,
7794            created_at_unix_ms: now,
7795            updated_at_unix_ms: now,
7796            last_error: None,
7797        })
7798    }
7799
7800    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7801        let mut records = self
7802            .session_records
7803            .lock()
7804            .unwrap_or_else(|poisoned| poisoned.into_inner());
7805        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7806            return Err(failure(
7807                NodeFailureCode::BackendBusy,
7808                "managed session record capacity is full; forget an inactive record first",
7809            ));
7810        }
7811        if let Some(identity) = record.provider_session.as_ref() {
7812            if records.records.values().any(|candidate| {
7813                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7814                    session_registry::same_provider_session(
7815                        &candidate.provider,
7816                        candidate_identity,
7817                        &record.provider,
7818                        identity,
7819                    )
7820                })
7821            }) {
7822                return Err(failure(
7823                    NodeFailureCode::SessionRecordConflict,
7824                    "provider session is already represented by another managed record",
7825                ));
7826            }
7827        }
7828        match records.records.entry(record.record_id.clone()) {
7829            std::collections::btree_map::Entry::Vacant(entry) => {
7830                entry.insert(record);
7831            }
7832            std::collections::btree_map::Entry::Occupied(_) => {
7833                return Err(failure(
7834                    NodeFailureCode::SessionRecordConflict,
7835                    "session record ID collision",
7836                ));
7837            }
7838        }
7839        Ok(())
7840    }
7841
7842    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7843        self.session_records
7844            .lock()
7845            .unwrap_or_else(|poisoned| poisoned.into_inner())
7846            .records
7847            .get(record_id)
7848            .cloned()
7849            .ok_or_else(|| {
7850                failure(
7851                    NodeFailureCode::UnknownSessionRecord,
7852                    "managed session record does not exist",
7853                )
7854            })
7855    }
7856
7857    fn revalidate_session_record_preview(
7858        &self,
7859        expected: &ManagedSessionRecord,
7860        identity: &ProviderSessionIdentity,
7861    ) -> Result<(), NodeFailure> {
7862        let current = self.record(&expected.record_id)?;
7863        let current_root = self.workspace_root(&current.workspace_id)?;
7864        if current.provider != expected.provider
7865            || current.workspace_id != expected.workspace_id
7866            || current.canonical_root != expected.canonical_root
7867            || current.provider_session.as_ref() != Some(identity)
7868            || !platform::roots_equal(
7869                &current_root,
7870                windows_path_text(&current.canonical_root),
7871            )
7872        {
7873            return Err(failure(
7874                NodeFailureCode::SessionRecordConflict,
7875                "managed session changed while its preview was loading",
7876            ));
7877        }
7878        Ok(())
7879    }
7880
7881    fn revalidate_session_record_context_export(
7882        &self,
7883        expected: &ManagedSessionRecord,
7884        identity: Option<&ProviderSessionIdentity>,
7885        session: &SessionAddress,
7886        allow_clean_detachment: bool,
7887    ) -> Result<(), NodeFailure> {
7888        let current = self.record(&expected.record_id)?;
7889        let current_root = self.workspace_root(&current.workspace_id)?;
7890        let snapshot = self.internal_session_snapshot(session)?;
7891        let binding = self
7892            .session_bindings
7893            .lock()
7894            .unwrap_or_else(|poisoned| poisoned.into_inner())
7895            .get(&session.session.instance_id)
7896            .cloned()
7897            .ok_or_else(|| {
7898                failure(
7899                    NodeFailureCode::SessionRecordConflict,
7900                    "managed session runtime binding disappeared during context export",
7901                )
7902            })?;
7903        if !session_record_context_export_binding_is_exact(
7904            expected,
7905            &current,
7906            identity,
7907            session,
7908            &binding,
7909            &snapshot.agent_id,
7910            &current_root,
7911            allow_clean_detachment,
7912        ) || !session_record_context_export_source_is_usable(
7913            &current,
7914            &snapshot.status,
7915            allow_clean_detachment,
7916        ) {
7917            return Err(failure(
7918                NodeFailureCode::SessionRecordConflict,
7919                "managed session binding changed during context export",
7920            ));
7921        }
7922        Ok(())
7923    }
7924
7925    fn revalidate_loaded_history(
7926        &self,
7927        session: &SessionAddress,
7928        candidate_id: &str,
7929        loaded: &HistorySessionRecord,
7930    ) -> Result<(), NodeFailure> {
7931        let snapshot = self.internal_session_snapshot(session)?;
7932        if snapshot.history.pending.is_some()
7933            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7934            || snapshot.history.loaded.as_ref() != Some(loaded)
7935        {
7936            return Err(failure(
7937                NodeFailureCode::SessionRecordConflict,
7938                "managed session history changed during context export",
7939            ));
7940        }
7941        Ok(())
7942    }
7943
7944    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7945        self.session_records
7946            .lock()
7947            .unwrap_or_else(|poisoned| poisoned.into_inner())
7948            .records
7949            .remove(record_id)
7950    }
7951
7952    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7953        if self.destroy_record_materialization(record_id)? {
7954            self.publish(NodeEvent::SessionRecordRemoved {
7955                record_id: record_id.clone(),
7956            });
7957            return Ok(());
7958        }
7959        let transaction = self
7960            .state_transaction
7961            .lock()
7962            .unwrap_or_else(|poisoned| poisoned.into_inner());
7963        let Some(removed) = self.remove_record_memory(record_id) else {
7964            return Ok(());
7965        };
7966        if let Err(error) = self.persist_state_locked() {
7967            let _ = self.insert_record(removed);
7968            return Err(persistence_failure(error));
7969        }
7970        drop(transaction);
7971        self.publish(NodeEvent::SessionRecordRemoved {
7972            record_id: record_id.clone(),
7973        });
7974        Ok(())
7975    }
7976
7977    fn publish_record(&self, record: ManagedSessionRecord) {
7978        self.publish(NodeEvent::SessionRecordUpserted { record });
7979    }
7980
7981    fn mark_record_error(
7982        &self,
7983        record_id: &SessionRecordId,
7984        message: &str,
7985    ) -> Result<(), NodeFailure> {
7986        let transaction = self
7987            .state_transaction
7988            .lock()
7989            .unwrap_or_else(|poisoned| poisoned.into_inner());
7990        let previous = {
7991            let mut records = self
7992                .session_records
7993                .lock()
7994                .unwrap_or_else(|poisoned| poisoned.into_inner());
7995            let Some(record) = records.records.get_mut(record_id) else {
7996                return Ok(());
7997            };
7998            let previous = record.clone();
7999            record.state = ManagedSessionState::Unavailable;
8000            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8001            record.updated_at_unix_ms = unix_time_ms();
8002            previous
8003        };
8004        if let Err(error) = self.persist_state_locked() {
8005            self.session_records
8006                .lock()
8007                .unwrap_or_else(|poisoned| poisoned.into_inner())
8008                .records
8009                .insert(record_id.clone(), previous);
8010            return Err(persistence_failure(error));
8011        }
8012        let record = self.record(record_id)?;
8013        drop(transaction);
8014        self.publish_record(record);
8015        Ok(())
8016    }
8017
8018    fn rename_session_record(
8019        &self,
8020        record_id: &SessionRecordId,
8021        display_name: String,
8022    ) -> Result<ManagedSessionRecord, NodeFailure> {
8023        validate_display_name(&display_name)
8024            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8025        let _transaction = self
8026            .state_transaction
8027            .lock()
8028            .unwrap_or_else(|poisoned| poisoned.into_inner());
8029        let previous = {
8030            let mut records = self
8031                .session_records
8032                .lock()
8033                .unwrap_or_else(|poisoned| poisoned.into_inner());
8034            let record = records.records.get_mut(record_id).ok_or_else(|| {
8035                failure(
8036                    NodeFailureCode::UnknownSessionRecord,
8037                    "managed session record does not exist",
8038                )
8039            })?;
8040            let previous = record.clone();
8041            record.display_name = display_name;
8042            record.updated_at_unix_ms = unix_time_ms();
8043            previous
8044        };
8045        if let Err(error) = self.persist_state_locked() {
8046            self.session_records
8047                .lock()
8048                .unwrap_or_else(|poisoned| poisoned.into_inner())
8049                .records
8050                .insert(record_id.clone(), previous);
8051            return Err(persistence_failure(error));
8052        }
8053        let updated = self.record(record_id)?;
8054        self.publish_record(updated.clone());
8055        Ok(updated)
8056    }
8057
8058    fn set_session_task(
8059        &self,
8060        record_id: &SessionRecordId,
8061        expected_revision: u64,
8062        target: SessionTaskTargetV1,
8063    ) -> Result<ManagedSessionRecord, NodeFailure> {
8064        let transaction = self
8065            .state_transaction
8066            .lock()
8067            .unwrap_or_else(|poisoned| poisoned.into_inner());
8068        let current = self.record(record_id)?;
8069        let current_task_id = current
8070            .task_binding
8071            .as_ref()
8072            .and_then(|binding| binding.task_id.as_ref());
8073        let current_revision = current
8074            .task_binding
8075            .as_ref()
8076            .map_or(0, |binding| binding.revision);
8077        if expected_revision != current_revision {
8078            return Err(failure(
8079                NodeFailureCode::SessionRecordConflict,
8080                "managed session task binding revision changed",
8081            ));
8082        }
8083        let exact = match &target {
8084            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8085            SessionTaskTargetV1::Clear => current_task_id.is_none(),
8086            SessionTaskTargetV1::New => false,
8087        };
8088        if exact {
8089            return Ok(current);
8090        }
8091        let revision = current_revision.checked_add(1).ok_or_else(|| {
8092            failure(
8093                NodeFailureCode::SessionRecordConflict,
8094                "managed session task binding revision is exhausted",
8095            )
8096        })?;
8097        let task_id = match target {
8098            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8099            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8100            SessionTaskTargetV1::Clear => None,
8101        };
8102        let changed_at_unix_ms = unix_time_ms()
8103            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8104                failure(
8105                    NodeFailureCode::SessionRecordConflict,
8106                    "managed session record timestamp is exhausted",
8107                )
8108            })?);
8109        let updated = {
8110            let mut records = self
8111                .session_records
8112                .lock()
8113                .unwrap_or_else(|poisoned| poisoned.into_inner());
8114            let record = records.records.get_mut(record_id).ok_or_else(|| {
8115                failure(
8116                    NodeFailureCode::UnknownSessionRecord,
8117                    "managed session record does not exist",
8118                )
8119            })?;
8120            record.task_binding = Some(SessionTaskBindingV1 {
8121                revision,
8122                task_id,
8123                changed_at_unix_ms,
8124            });
8125            record.updated_at_unix_ms = changed_at_unix_ms;
8126            record.clone()
8127        };
8128        if let Err(error) = self.persist_state_locked() {
8129            self.session_records
8130                .lock()
8131                .unwrap_or_else(|poisoned| poisoned.into_inner())
8132                .records
8133                .insert(record_id.clone(), current);
8134            return Err(persistence_failure(error));
8135        }
8136        drop(transaction);
8137        self.publish_record(updated.clone());
8138        Ok(updated)
8139    }
8140
8141    fn index_provider_session(
8142        &self,
8143        workspace_id: WorkspaceId,
8144        provider: AgentId,
8145        identity: ProviderSessionIdentity,
8146        display_name: String,
8147    ) -> Result<ManagedSessionRecord, NodeFailure> {
8148        self.index_provider_session_with_policy(
8149            workspace_id,
8150            provider,
8151            identity,
8152            display_name,
8153            false,
8154        )
8155    }
8156
8157    fn index_provider_session_with_policy(
8158        &self,
8159        workspace_id: WorkspaceId,
8160        provider: AgentId,
8161        identity: ProviderSessionIdentity,
8162        display_name: String,
8163        allow_validated_transcript_path: bool,
8164    ) -> Result<ManagedSessionRecord, NodeFailure> {
8165        validate_display_name(&display_name)
8166            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8167        identity
8168            .validate()
8169            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8170        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8171            return Err(failure(
8172                NodeFailureCode::InvalidRequest,
8173                "provider session reference index v1 does not accept transcript paths",
8174            ));
8175        }
8176        let canonical_root = self.workspace_root(&workspace_id)?;
8177        if !self.enabled_providers.contains(&provider) {
8178            return Err(failure(
8179                NodeFailureCode::InvalidRequest,
8180                "provider is not enabled on this node",
8181            ));
8182        }
8183
8184        let transaction = self
8185            .state_transaction
8186            .lock()
8187            .unwrap_or_else(|poisoned| poisoned.into_inner());
8188        {
8189            let records = self
8190                .session_records
8191                .lock()
8192                .unwrap_or_else(|poisoned| poisoned.into_inner());
8193            if let Some(existing) = records.records.values().find(|candidate| {
8194                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8195                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
8196                })
8197            }) {
8198                if existing.provider == provider && existing.workspace_id == workspace_id {
8199                    return Ok(existing.clone());
8200                }
8201                return Err(failure(
8202                    NodeFailureCode::SessionRecordConflict,
8203                    "provider session reference conflicts with another provider or workspace",
8204                ));
8205            }
8206            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8207                return Err(failure(
8208                    NodeFailureCode::BackendBusy,
8209                    "managed session record capacity is full; forget an inactive record first",
8210                ));
8211            }
8212        }
8213
8214        let now = unix_time_ms();
8215        let record = ManagedSessionRecord {
8216            record_id: self.allocate_record_id()?,
8217            display_name,
8218            provider,
8219            mode: SessionMode::Pty,
8220            state: ManagedSessionState::Dormant,
8221            workspace_id,
8222            canonical_root: opaque_windows_path(canonical_root),
8223            provider_session: Some(identity),
8224            active_session: None,
8225            environment_profile: None,
8226            bundle: None,
8227            context_id: None,
8228            context: None,
8229            exported_context: None,
8230            task_binding: None,
8231            created_at_unix_ms: now,
8232            updated_at_unix_ms: now,
8233            last_error: None,
8234        };
8235        self.insert_record(record.clone())?;
8236        if let Err(error) = self.persist_state_locked() {
8237            self.remove_record_memory(&record.record_id);
8238            return Err(persistence_failure(error));
8239        }
8240        drop(transaction);
8241        self.publish_record(record.clone());
8242        Ok(record)
8243    }
8244
8245    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8246        self.session_bindings
8247            .lock()
8248            .unwrap_or_else(|poisoned| poisoned.into_inner())
8249            .iter()
8250            .find_map(|(instance_id, binding)| {
8251                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8252                    workspace_id: binding.workspace_id.clone(),
8253                    session: SessionKey {
8254                        instance_id: *instance_id,
8255                        generation: binding.generation,
8256                    },
8257                })
8258            })
8259    }
8260
8261    async fn forget_session_record(
8262        &self,
8263        record_id: &SessionRecordId,
8264    ) -> Result<(), NodeFailure> {
8265        let record = self.record(record_id)?;
8266        if record.active_session.is_some()
8267            || matches!(
8268                record.state,
8269                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8270            )
8271        {
8272            return Err(failure(
8273                NodeFailureCode::SessionRecordBusy,
8274                "stop the managed session before forgetting it",
8275            ));
8276        }
8277        if let Some(address) = self.bound_address_for_record(record_id) {
8278            self.remove_session(&address).await?;
8279        }
8280        self.remove_managed_session_record(record_id, &record.workspace_id).await
8281    }
8282
8283    /// Removes one managed session record from durable state and notifies
8284    /// every listener -- the shared transaction body behind both
8285    /// `forget_session_record`'s operator-driven path (busy-checked above
8286    /// it, and clears any still-live session binding before calling in
8287    /// here) and the retention sweep (`retire_unavailable_session_records`,
8288    /// which only ever selects `ManagedSessionState::Unavailable` records
8289    /// that are already known never-busy and never bound, so it calls
8290    /// straight into this with neither check). Cleans a managed-worktree
8291    /// lease if the record held one, removes the record's environment
8292    /// materialization (or, absent one, removes it from memory directly),
8293    /// persists under `state_transaction` with rollback on persist failure,
8294    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8295    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8296    /// exactly as it does for an operator-driven forget.
8297    async fn remove_managed_session_record(
8298        &self,
8299        record_id: &SessionRecordId,
8300        workspace_id: &WorkspaceId,
8301    ) -> Result<(), NodeFailure> {
8302        let managed_lease_id = self.managed_worktrees.lock()
8303            .unwrap_or_else(|poisoned| poisoned.into_inner())
8304            .lease_for_workspace(workspace_id);
8305        if !self.destroy_record_materialization(record_id)? {
8306            let transaction = self
8307                .state_transaction
8308                .lock()
8309                .unwrap_or_else(|poisoned| poisoned.into_inner());
8310            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8311                failure(
8312                    NodeFailureCode::UnknownSessionRecord,
8313                    "managed session record does not exist",
8314                )
8315            })?;
8316            if let Err(error) = self.persist_state_locked() {
8317                let _ = self.insert_record(removed);
8318                return Err(persistence_failure(error));
8319            }
8320            drop(transaction);
8321        }
8322        self.publish(NodeEvent::SessionRecordRemoved {
8323            record_id: record_id.clone(),
8324        });
8325        if let Some(lease_id) = managed_lease_id {
8326            if let Some(snapshot) = self.managed_worktrees.lock()
8327                .unwrap_or_else(|poisoned| poisoned.into_inner())
8328                .get(&lease_id)
8329                .map(ManagedWorktreeLeaseRecord::snapshot)
8330            {
8331                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8332            }
8333            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8334        }
8335        Ok(())
8336    }
8337
8338    /// Retires `ManagedSessionState::Unavailable` records against the
8339    /// node's configured `session_record_retention` age/keep-N policy.
8340    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8341    /// `Dormant` is resumable and folding it into this sweep would delete
8342    /// records a caller can still act on. Both policy fields default to
8343    /// `0` (disabled); when both are `0` this is a no-op. When both are
8344    /// set, a record is retired only if it fails BOTH tests (older than
8345    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8346    /// workspace); when only one is set, that one alone decides. Called
8347    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8348    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8349    /// loop -- see both call sites.
8350    async fn retire_unavailable_session_records(&self) {
8351        let policy = self.session_record_retention;
8352        let age_enabled = policy.age_ms > 0;
8353        let keep_enabled = policy.keep_per_workspace > 0;
8354        if !age_enabled && !keep_enabled {
8355            return;
8356        }
8357        let now = unix_time_ms();
8358        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8359            self.session_records
8360                .lock()
8361                .unwrap_or_else(|poisoned| poisoned.into_inner())
8362                .records
8363                .values()
8364                .filter(|record| record.state == ManagedSessionState::Unavailable)
8365                .map(|record| {
8366                    (
8367                        record.record_id.clone(),
8368                        record.workspace_id.clone(),
8369                        record.provider.clone(),
8370                        record.updated_at_unix_ms,
8371                    )
8372                })
8373                .collect()
8374        };
8375        if candidates.is_empty() {
8376            return;
8377        }
8378        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8379            BTreeMap::new();
8380        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8381            by_workspace
8382                .entry(workspace_id)
8383                .or_default()
8384                .push((record_id, provider, updated_at_unix_ms));
8385        }
8386        let reason = if age_enabled && keep_enabled {
8387            "age+keep-n"
8388        } else if age_enabled {
8389            "age"
8390        } else {
8391            "keep-n"
8392        };
8393        let mut retired: u64 = 0;
8394        for (workspace_id, mut records) in by_workspace {
8395            // Newest `updated_at_unix_ms` first, so index `>=
8396            // keep_per_workspace` names exactly "beyond the newest N".
8397            records.sort_by(|left, right| right.2.cmp(&left.2));
8398            for (index, (record_id, provider, updated_at_unix_ms)) in
8399                records.into_iter().enumerate()
8400            {
8401                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8402                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8403                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8404                let should_retire = if age_enabled && keep_enabled {
8405                    age_hit && keep_hit
8406                } else {
8407                    age_hit || keep_hit
8408                };
8409                if !should_retire {
8410                    continue;
8411                }
8412                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8413                    Ok(()) => {
8414                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8415                        retired += 1;
8416                        tracing::info!(
8417                            node_id = %self.node_id,
8418                            record_id = %record_id,
8419                            workspace_id = %workspace_id,
8420                            provider = %provider,
8421                            age_ms = record_age_ms,
8422                            reason,
8423                            "managed session record retired"
8424                        );
8425                    }
8426                    Err(error) => tracing::warn!(
8427                        node_id = %self.node_id,
8428                        record_id = %record_id,
8429                        workspace_id = %workspace_id,
8430                        code = ?error.code,
8431                        cause = %error.message,
8432                        "session record retention sweep failed to retire a record"
8433                    ),
8434                }
8435            }
8436        }
8437        if retired > 0 {
8438            let remaining_unavailable = self
8439                .session_records
8440                .lock()
8441                .unwrap_or_else(|poisoned| poisoned.into_inner())
8442                .records
8443                .values()
8444                .filter(|record| record.state == ManagedSessionState::Unavailable)
8445                .count();
8446            tracing::info!(
8447                node_id = %self.node_id,
8448                retired,
8449                remaining_unavailable,
8450                "session record retention sweep completed"
8451            );
8452        }
8453    }
8454
8455    async fn resume_session_record(
8456        &self,
8457        record_id: &SessionRecordId,
8458        terminal_size: gate4agent_types::TerminalSize,
8459        initial_prompt: Option<String>,
8460    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8461        let record = self.record(record_id)?;
8462        if record.active_session.is_some()
8463            || matches!(
8464                record.state,
8465                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8466            )
8467        {
8468            return Err(failure(
8469                NodeFailureCode::SessionRecordBusy,
8470                "managed session already has a live runtime binding",
8471            ));
8472        }
8473        let identity = record.provider_session.clone().ok_or_else(|| {
8474            failure(
8475                NodeFailureCode::SessionRecordNotResumable,
8476                "managed session has no verified provider session identity",
8477            )
8478        })?;
8479        let working_directory = self.workspace_root(&record.workspace_id)?;
8480        if !platform::roots_equal(
8481            &working_directory,
8482            windows_path_text(&record.canonical_root),
8483        ) {
8484            return Err(failure(
8485                NodeFailureCode::SessionRecordConflict,
8486                "managed session workspace root changed; refusing to resume in another directory",
8487            ));
8488        }
8489        {
8490            let records = self
8491                .session_records
8492                .lock()
8493                .unwrap_or_else(|poisoned| poisoned.into_inner());
8494            if records.records.values().any(|candidate| {
8495                candidate.record_id != *record_id
8496                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8497                        session_registry::same_provider_session(
8498                            &candidate.provider,
8499                            candidate_identity,
8500                            &record.provider,
8501                            &identity,
8502                        )
8503                    })
8504                    && (candidate.active_session.is_some()
8505                        || matches!(
8506                            candidate.state,
8507                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8508                        ))
8509            }) {
8510                return Err(failure(
8511                    NodeFailureCode::SessionRecordConflict,
8512                    "provider session is already active through another managed record",
8513                ));
8514            }
8515        }
8516        let runtime_requirement = if initial_prompt.is_some() {
8517            ProviderRuntimeRequirement::ResumeWithPrompt
8518        } else {
8519            ProviderRuntimeRequirement::Resume
8520        };
8521        let runtime_policy = self.admit_provider_runtime(
8522            &record.provider,
8523            runtime_requirement,
8524        )
8525        .await?;
8526        let request = ResumeLaunchRequest {
8527            working_directory,
8528            terminal_size,
8529            initial_prompt,
8530        };
8531        request
8532            .validate()
8533            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8534        self.ensure_binding_capacity()?;
8535        let resolved_materialization = match self.resolve_record_materialization(
8536            record_id,
8537            &record.provider,
8538            record.mode,
8539            record.environment_profile.as_ref(),
8540            record.bundle.as_ref(),
8541            record.context.as_ref(),
8542        ) {
8543            Ok(materialization) => materialization,
8544            Err(error) => {
8545                let label = match error.code {
8546                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8547                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8548                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8549                    _ => "environment-profile-unavailable",
8550                };
8551                self.mark_record_error(record_id, label)?;
8552                return Err(error);
8553            }
8554        };
8555        let (materialization_id, environment_overlay) = match resolved_materialization {
8556            Some((id, overlay)) => (Some(id), overlay),
8557            None => (None, None),
8558        };
8559        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8560        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8561            instance_id,
8562            &record.provider,
8563            record.mode,
8564            record.environment_profile.as_ref(),
8565        ) {
8566            Ok(selection) => selection,
8567            Err(error) => {
8568                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8569                return Err(error);
8570            }
8571        };
8572        let mut instance_overlay = environment_overlay
8573            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8574            .transpose()?
8575            .flatten();
8576        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8577            self.remove_session(&stale_address).await?;
8578        }
8579        let address = SessionAddress {
8580            workspace_id: record.workspace_id.clone(),
8581            session: SessionKey {
8582                instance_id,
8583                generation: SessionGeneration(1),
8584            },
8585        };
8586        let transaction = self
8587            .state_transaction
8588            .lock()
8589            .unwrap_or_else(|poisoned| poisoned.into_inner());
8590        let current = self.record(record_id)?;
8591        if current.active_session.is_some()
8592            || matches!(
8593                current.state,
8594                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8595            )
8596        {
8597            return Err(failure(
8598                NodeFailureCode::SessionRecordBusy,
8599                "managed session became active while resume was being prepared",
8600            ));
8601        }
8602        self.bind_managed_session_with_materialization(
8603            &address,
8604            record_id.clone(),
8605            runtime_policy,
8606            record.environment_profile.clone(),
8607            record.bundle.clone(),
8608            record.context.clone(),
8609            materialization_id,
8610        );
8611        if let Some(selection) = environment_selection.take() {
8612            selection.retain();
8613        }
8614        if let Some(overlay) = instance_overlay.take() {
8615            overlay.retain();
8616        }
8617        drop(transaction);
8618
8619        let transport = match record.mode {
8620            SessionMode::Pty => TransportKind::Pty,
8621            SessionMode::Inline => TransportKind::Pipe,
8622            SessionMode::Acp => TransportKind::Acp,
8623        };
8624        let agent_id = record.provider.clone();
8625        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8626        if let Err(error) = self
8627            .dispatch_bounded(
8628                ControlCommand::Register {
8629                    instance_id,
8630                    agent_id,
8631                    transport,
8632                },
8633                dispatch_timeout,
8634            )
8635            .await
8636        {
8637            if let Some(binding) = self.remove_binding(&address) {
8638                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8639            }
8640            return Err(error);
8641        }
8642        let command = self.prepare_command(ControlCommand::Resume {
8643            instance_id,
8644            target: ResumeTarget::ProviderSession { identity },
8645            runtime_policy,
8646            request,
8647        });
8648        let started_after = self.current_sequence();
8649        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8650            self.rollback_spawn(&address, dispatch_timeout).await?;
8651            return Err(error);
8652        }
8653        if let Err(error) = self.dispatch_envelope(command) {
8654            self.clear_armed_resume(&address);
8655            self.rollback_spawn(&address, dispatch_timeout).await?;
8656            return Err(error);
8657        }
8658        match self
8659            .wait_for_record_resume(&address, started_after)
8660            .await
8661        {
8662            Ok(settled_address) => {
8663                let managed_lease_id = {
8664                    self.managed_worktrees.lock()
8665                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8666                        .lease_for_workspace(&record.workspace_id)
8667                };
8668                if let Some(lease_id) = managed_lease_id {
8669                    {
8670                        let mut bindings = self.session_bindings.lock()
8671                            .unwrap_or_else(|poisoned| poisoned.into_inner());
8672                        let binding = bindings.get_mut(&settled_address.session.instance_id)
8673                            .ok_or_else(|| failure(
8674                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8675                                "resumed managed session lost its exact binding",
8676                            ))?;
8677                        if binding.generation != settled_address.session.generation {
8678                            return Err(failure(
8679                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
8680                                "resumed managed session generation diverged",
8681                            ));
8682                        }
8683                        binding.managed_worktree_lease_id = Some(lease_id.clone());
8684                    }
8685                    let snapshot = self.managed_worktrees.lock()
8686                        .unwrap_or_else(|poisoned| poisoned.into_inner())
8687                        .bind_session(
8688                            &lease_id,
8689                            ManagedWorktreeSessionHolder {
8690                                incarnation_id: self.incarnation_id,
8691                                instance_id: settled_address.session.instance_id,
8692                                generation: settled_address.session.generation,
8693                            },
8694                            Some(record_id.clone()),
8695                            unix_time_ms(),
8696                        ).map_err(|message| failure(
8697                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
8698                            &message,
8699                        ))?;
8700                    if let Err(error) = self.persist_state() {
8701                        let _ = self.remove_session(&settled_address).await;
8702                        return Err(persistence_failure(error));
8703                    }
8704                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8705                }
8706                Ok((self.record(record_id)?, settled_address))
8707            }
8708            Err(error) => {
8709                if matches!(
8710                    error.code,
8711                    NodeFailureCode::SessionRecordNotResumable
8712                        | NodeFailureCode::BackendOperationFailed
8713                ) {
8714                    if let Some(bound) = self.bound_address_for_record(record_id) {
8715                        let _ = self.remove_session(&bound).await;
8716                    }
8717                }
8718                Err(error)
8719            }
8720        }
8721    }
8722
8723    async fn wait_for_record_resume(
8724        &self,
8725        initial_address: &SessionAddress,
8726        after_sequence: u64,
8727    ) -> Result<SessionAddress, NodeFailure> {
8728        let deadline = Instant::now()
8729            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8730        let mut scan_after = after_sequence;
8731        loop {
8732            let events = self
8733                .history
8734                .lock()
8735                .unwrap_or_else(|poisoned| poisoned.into_inner())
8736                .events
8737                .iter()
8738                .filter(|event| event.sequence > scan_after)
8739                .cloned()
8740                .collect::<Vec<_>>();
8741            for envelope in events {
8742                scan_after = scan_after.max(envelope.sequence);
8743                let NodeEvent::Control { address, event } = envelope.event else {
8744                    continue;
8745                };
8746                if address.session.instance_id != initial_address.session.instance_id {
8747                    continue;
8748                }
8749                match event.event {
8750                    ControlEventKind::Resumed { .. } => return Ok(address),
8751                    ControlEventKind::ResumeDenied { reason } => {
8752                        return Err(failure(
8753                            NodeFailureCode::SessionRecordNotResumable,
8754                            &reason,
8755                        ));
8756                    }
8757                    ControlEventKind::ResumeFailed { message }
8758                    | ControlEventKind::CommandRejected { message } => {
8759                        return Err(failure(
8760                            NodeFailureCode::BackendOperationFailed,
8761                            &message,
8762                        ));
8763                    }
8764                    _ => {}
8765                }
8766            }
8767            if Instant::now() >= deadline {
8768                return Err(failure(
8769                    NodeFailureCode::BackendBusy,
8770                    "managed resume did not settle before the bounded deadline",
8771                ));
8772            }
8773            sleep(Duration::from_millis(5)).await;
8774        }
8775    }
8776
8777    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8778        self.workspaces
8779            .read()
8780            .unwrap_or_else(|poisoned| poisoned.into_inner())
8781            .get(workspace_id)
8782            .cloned()
8783            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8784    }
8785
8786    fn workspace_roots(&self) -> Vec<PathBuf> {
8787        self.workspaces
8788            .read()
8789            .unwrap_or_else(|poisoned| poisoned.into_inner())
8790            .values()
8791            .map(PathBuf::from)
8792            .collect()
8793    }
8794
8795    async fn inspect_workspace(
8796        &self,
8797        workspace_id: WorkspaceId,
8798    ) -> Result<WorkspaceInspection, NodeFailure> {
8799        let _permit = self
8800            .inspection_slots
8801            .clone()
8802            .try_acquire_owned()
8803            .map_err(|_| {
8804                tracing::warn!(
8805                    workspace_id = %workspace_id,
8806                    "workspace inspection rejected: inspection capacity is busy",
8807                );
8808                failure(
8809                    NodeFailureCode::BackendBusy,
8810                    "workspace inspection capacity is busy",
8811                )
8812            })?;
8813        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8814            tracing::warn!(
8815                workspace_id = %workspace_id,
8816                code = ?error.code,
8817                "workspace inspection rejected: workspace is not registered",
8818            );
8819            error
8820        })?;
8821        let tree_root = canonical_root.clone();
8822        let started_at = Instant::now();
8823        let time_budget_ms = workspace_inspection_time_budget_ms();
8824        let entry_cap = workspace_inspection_entry_cap();
8825        let deadline = started_at + Duration::from_millis(time_budget_ms);
8826        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8827            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8828        })
8829        .await
8830        .map_err(|error| {
8831            tracing::warn!(
8832                workspace_id = %workspace_id,
8833                error = %error,
8834                "workspace inspection rejected: walk task failed",
8835            );
8836            failure(
8837                NodeFailureCode::BackendOperationFailed,
8838                &format!("workspace inspection task failed: {error}"),
8839            )
8840        })?;
8841        let (mut git, git_time_budget_exceeded) =
8842            inspect_git_workspace(&canonical_root, deadline).await;
8843        if !git.worktrees.is_empty() {
8844            let registered = self
8845                .workspaces
8846                .read()
8847                .unwrap_or_else(|poisoned| poisoned.into_inner())
8848                .iter()
8849                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8850                .collect::<Vec<_>>();
8851            for worktree in &mut git.worktrees {
8852                worktree.workspace_id = registered
8853                    .iter()
8854                    .find(|(_, root)| {
8855                        worktree_paths_equal(root, windows_path_text(&worktree.path))
8856                    })
8857                    .map(|(workspace_id, _)| workspace_id.clone());
8858            }
8859        }
8860        if git.is_repository {
8861            git.managed_worktree = self
8862                .managed_worktrees
8863                .lock()
8864                .unwrap_or_else(|poisoned| poisoned.into_inner())
8865                .git_scope(&workspace_id, git.branch.as_deref());
8866        }
8867        let truncation = workspace_inspection_truncation(
8868            &walk_budget,
8869            git_time_budget_exceeded,
8870            started_at.elapsed(),
8871        );
8872        if let Some(truncation) = &truncation {
8873            tracing::info!(
8874                workspace_id = %workspace_id,
8875                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8876                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8877                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8878                entries_visited = truncation.entries_visited,
8879                elapsed_ms = truncation.elapsed_ms,
8880                "workspace inspection truncated by its inner budget",
8881            );
8882        }
8883        Ok(WorkspaceInspection {
8884            workspace_id,
8885            entries,
8886            tree_truncated,
8887            git,
8888            truncation,
8889        })
8890    }
8891
8892    async fn browse_host_directories(
8893        &self,
8894        directory: Option<OpaqueHostPath>,
8895        after: Option<OpaqueHostPath>,
8896    ) -> Result<HostDirectoryListing, NodeFailure> {
8897        let permit = self
8898            .inspection_slots
8899            .clone()
8900            .try_acquire_owned()
8901            .map_err(|_| {
8902                failure(
8903                    NodeFailureCode::BackendBusy,
8904                    "host directory browse capacity is busy",
8905                )
8906            })?;
8907        let task = tokio::task::spawn_blocking(move || {
8908            let _permit = permit;
8909            browse_host_directories(directory, after)
8910        });
8911        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8912            .await
8913            .map_err(|_| {
8914                failure(
8915                    NodeFailureCode::HostDirectoryReadTimedOut,
8916                    "host directory browse exceeded its bounded deadline",
8917                )
8918            })?
8919            .map_err(|_| {
8920                failure(
8921                    NodeFailureCode::HostDirectoryReadFailed,
8922                    "host directory browse task failed",
8923                )
8924            })?
8925            .map_err(host_directory_failure)
8926    }
8927
8928    async fn read_workspace_file(
8929        &self,
8930        workspace_id: WorkspaceId,
8931        path: RepositoryPath,
8932    ) -> Result<WorkspaceFileRead, NodeFailure> {
8933        let canonical_root = self.workspace_root(&workspace_id)?;
8934        let permit = self
8935            .inspection_slots
8936            .clone()
8937            .try_acquire_owned()
8938            .map_err(|_| {
8939                failure(
8940                    NodeFailureCode::BackendBusy,
8941                    "workspace file read capacity is busy",
8942                )
8943            })?;
8944        let path_to_read = path.clone();
8945        let task = tokio::task::spawn_blocking(move || {
8946            let _permit = permit;
8947            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8948        });
8949        let content = timeout(
8950            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8951            task,
8952        )
8953        .await
8954        .map_err(|_| {
8955            failure(
8956                NodeFailureCode::RepositoryFileReadTimedOut,
8957                "repository file read exceeded its bounded deadline",
8958            )
8959        })?
8960        .map_err(|_| {
8961            failure(
8962                NodeFailureCode::RepositoryFileReadFailed,
8963                "repository file read task failed",
8964            )
8965        })?
8966        .map_err(workspace_file_failure)?;
8967        let content = match content {
8968            WorkspaceFileBytes::Utf8(text) => {
8969                let revision = Some(workspace_file_revision(text.as_bytes()));
8970                return Ok(WorkspaceFileRead {
8971                    workspace_id,
8972                    path,
8973                    content: WorkspaceFileContent::Utf8 {
8974                        byte_len: u32::try_from(text.len())
8975                            .expect("bounded workspace text length must fit u32"),
8976                        text,
8977                    },
8978                    revision,
8979                });
8980            }
8981            WorkspaceFileBytes::NonUtf8 { byte_length } => {
8982                WorkspaceFileContent::NonUtf8 {
8983                    byte_len: u32::try_from(byte_length)
8984                        .expect("bounded workspace file length must fit u32"),
8985                }
8986            }
8987            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8988                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8989            },
8990        };
8991        Ok(WorkspaceFileRead {
8992            workspace_id,
8993            path,
8994            content,
8995            revision: None,
8996        })
8997    }
8998
8999    async fn write_workspace_file(
9000        &self,
9001        workspace_id: WorkspaceId,
9002        path: RepositoryPath,
9003        expected_revision: WorkspaceFileRevision,
9004        text: String,
9005    ) -> Result<WorkspaceFileRead, NodeFailure> {
9006        let canonical_root = self.workspace_root(&workspace_id)?;
9007        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9008            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9009        })?;
9010        let path_to_write = path.clone();
9011        let expected = expected_revision.as_str().to_owned();
9012        let text_to_write = text.clone();
9013        let task = tokio::task::spawn_blocking(move || {
9014            let _permit = permit;
9015            write_workspace_file_to_disk(
9016                Path::new(&canonical_root),
9017                &path_to_write,
9018                &expected,
9019                &text_to_write,
9020            )
9021        });
9022        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9023            .await
9024            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9025            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9026            .map_err(workspace_file_write_failure)?;
9027        Ok(WorkspaceFileRead {
9028            workspace_id,
9029            path,
9030            content: WorkspaceFileContent::Utf8 {
9031                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9032                text,
9033            },
9034            revision: Some(WorkspaceFileRevision::new(revision)
9035                .expect("workspace writer returns a valid SHA-256 revision")),
9036        })
9037    }
9038
9039    async fn create_workspace_file(
9040        &self,
9041        workspace_id: WorkspaceId,
9042        path: RepositoryPath,
9043    ) -> Result<WorkspaceFileRead, NodeFailure> {
9044        let canonical_root = self.workspace_root(&workspace_id)?;
9045        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9046            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9047        })?;
9048        let path_to_create = path.clone();
9049        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9050        let task_commit_state = Arc::clone(&commit_state);
9051        let task = tokio::task::spawn_blocking(move || {
9052            let _permit = permit;
9053            create_workspace_file_on_disk(
9054                Path::new(&canonical_root),
9055                &path_to_create,
9056                &task_commit_state,
9057            )
9058        });
9059        let revision = settle_workspace_entry_create(
9060            task,
9061            commit_state,
9062            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9063            "repository file create exceeded its bounded deadline",
9064            "repository file create task failed",
9065        )
9066        .await?;
9067        Ok(WorkspaceFileRead {
9068            workspace_id,
9069            path,
9070            content: WorkspaceFileContent::Utf8 {
9071                text: String::new(),
9072                byte_len: 0,
9073            },
9074            revision: Some(WorkspaceFileRevision::new(revision)
9075                .expect("workspace creator returns a valid SHA-256 revision")),
9076        })
9077    }
9078
9079    async fn create_workspace_directory(
9080        &self,
9081        workspace_id: WorkspaceId,
9082        path: RepositoryPath,
9083    ) -> Result<WorkspaceEntry, NodeFailure> {
9084        let canonical_root = self.workspace_root(&workspace_id)?;
9085        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9086            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9087        })?;
9088        let path_to_create = path.clone();
9089        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9090        let task_commit_state = Arc::clone(&commit_state);
9091        let task = tokio::task::spawn_blocking(move || {
9092            let _permit = permit;
9093            create_workspace_directory_on_disk(
9094                Path::new(&canonical_root),
9095                &path_to_create,
9096                &task_commit_state,
9097            )
9098        });
9099        settle_workspace_entry_create(
9100            task,
9101            commit_state,
9102            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9103            "repository directory create exceeded its bounded deadline",
9104            "repository directory create task failed",
9105        )
9106        .await?;
9107        Ok(WorkspaceEntry {
9108            relative_path: path,
9109            kind: WorkspaceEntryKind::Directory,
9110        })
9111    }
9112
9113    async fn read_git_history(
9114        &self,
9115        workspace_id: WorkspaceId,
9116        path: Option<RepositoryPath>,
9117        before: Option<GitObjectId>,
9118        limit: u16,
9119    ) -> Result<GitHistoryPage, NodeFailure> {
9120        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9121            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9122        })?;
9123        let root = self.workspace_root(&workspace_id)?;
9124        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9125    }
9126
9127    async fn read_git_diff(
9128        &self,
9129        workspace_id: WorkspaceId,
9130        request: GitDiffRequest,
9131    ) -> Result<GitDiff, NodeFailure> {
9132        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9133            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9134        })?;
9135        let root = self.workspace_root(&workspace_id)?;
9136        read_git_diff_bounded(&root, request).await
9137    }
9138
9139    fn native_session_route_root(
9140        &self,
9141        route: &NativeSessionCatalogRoute,
9142    ) -> Result<Option<String>, NodeFailure> {
9143        route
9144            .validate()
9145            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9146        match route.scope {
9147            gate4agent_types::NativeSessionCatalogScope::Workspace => route
9148                .workspace_id
9149                .as_ref()
9150                .map(|workspace_id| self.workspace_root(workspace_id))
9151                .transpose(),
9152            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9153        }
9154    }
9155
9156    fn project_native_session_entries(
9157        &self,
9158        route: &NativeSessionCatalogRoute,
9159        entries: Vec<ScopedNativeSessionCatalogEntry>,
9160    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9161        let current_root = route
9162            .workspace_id
9163            .as_ref()
9164            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9165        let records = self
9166            .session_records
9167            .lock()
9168            .unwrap_or_else(|poisoned| poisoned.into_inner());
9169        let mut seen_records = std::collections::HashSet::new();
9170        entries
9171            .into_iter()
9172            .map(|entry| {
9173                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9174                    let matches = records
9175                        .records
9176                        .values()
9177                        .filter(|record| {
9178                            record.provider == route.provider
9179                                && record.workspace_id == *workspace_id
9180                                && current_root.as_deref().is_some_and(|root| {
9181                                    platform::roots_equal(
9182                                        root,
9183                                        windows_path_text(&record.canonical_root),
9184                                    )
9185                                })
9186                                && record.provider_session.as_ref().is_some_and(|identity| {
9187                                    session_registry::same_provider_session(
9188                                        &record.provider,
9189                                        identity,
9190                                        &route.provider,
9191                                        &entry.provider_session,
9192                                    )
9193                                })
9194                        })
9195                        .map(|record| record.record_id.clone())
9196                        .collect::<Vec<_>>();
9197                    (matches.len() == 1
9198                        && seen_records.insert(matches[0].clone()))
9199                        .then(|| matches[0].clone())
9200                });
9201                let metadata = entry.metadata;
9202                crate::protocol::NativeSessionCatalogEntry {
9203                    selection_id: metadata.selection_id,
9204                    title: metadata.title,
9205                    modified_at_unix_ms: metadata.modified_at_unix_ms,
9206                    model: metadata.model,
9207                    message_count: metadata.message_count,
9208                    completed_turn_count: metadata.completed_turn_count,
9209                    external_group: entry.external_group,
9210                    record_id,
9211                }
9212            })
9213            .collect()
9214    }
9215
9216    async fn catalog_native_sessions(
9217        &self,
9218        route: NativeSessionCatalogRoute,
9219        limit: u16,
9220    ) -> Result<(
9221        Vec<crate::protocol::NativeSessionCatalogEntry>,
9222        crate::protocol::NativeSessionCatalogSummary,
9223    ), NodeFailure> {
9224        let canonical_root = self.native_session_route_root(&route)?;
9225        let registered_workspace_roots = self.workspace_roots();
9226        let provider = route.provider.clone();
9227        let scope = route.scope;
9228        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9229            failure(
9230                NodeFailureCode::InvalidRequest,
9231                "native session catalog is not configured",
9232            )
9233        })?;
9234        let _permit = Arc::clone(&self.inspection_slots)
9235            .try_acquire_owned()
9236            .map_err(|_| {
9237                failure(
9238                    NodeFailureCode::BackendBusy,
9239                    "native session catalog capacity is busy",
9240                )
9241            })?;
9242        let page = run_native_session_catalog_operation(
9243            catalog,
9244            "native session catalog",
9245            move |catalog| {
9246                catalog.catalog_initial_for_scope(
9247                    &provider,
9248                    scope,
9249                    canonical_root.as_deref().map(Path::new),
9250                    &registered_workspace_roots,
9251                    limit,
9252                )
9253            },
9254        )
9255        .await?;
9256        let summary = crate::protocol::NativeSessionCatalogSummary {
9257            catalog_revision: page.revision,
9258            recent_cutoff_unix_ms: page.cutoff_unix_ms,
9259            recent_total_count: page.recent_total_count,
9260            older_total_count: page.older_total_count,
9261            recent_next_after_selection_id: page.next_after_selection_id,
9262            recent_has_more: page.remaining_count > 0,
9263        };
9264        Ok((self.project_native_session_entries(&route, page.entries), summary))
9265    }
9266
9267    async fn page_native_sessions(
9268        &self,
9269        route: NativeSessionCatalogRoute,
9270        window: crate::protocol::NativeSessionCatalogWindow,
9271        catalog_revision: u64,
9272        recent_cutoff_unix_ms: u64,
9273        after_selection_id: Option<String>,
9274        limit: u16,
9275    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9276        let canonical_root = self.native_session_route_root(&route)?;
9277        let registered_workspace_roots = self.workspace_roots();
9278        let provider = route.provider.clone();
9279        let scope = route.scope;
9280        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9281            failure(
9282                NodeFailureCode::InvalidRequest,
9283                "native session catalog is not configured",
9284            )
9285        })?;
9286        let _permit = Arc::clone(&self.inspection_slots)
9287            .try_acquire_owned()
9288            .map_err(|_| {
9289                failure(
9290                    NodeFailureCode::BackendBusy,
9291                    "native session catalog capacity is busy",
9292                )
9293            })?;
9294        let page = run_native_session_catalog_operation(
9295            catalog,
9296            "native session catalog page",
9297            move |catalog| {
9298                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9299                    &provider,
9300                    scope,
9301                    canonical_root.as_deref().map(Path::new),
9302                    &registered_workspace_roots,
9303                    window,
9304                    catalog_revision,
9305                    recent_cutoff_unix_ms,
9306                    after_selection_id.as_deref(),
9307                    limit,
9308                ))
9309            },
9310        )
9311        .await?;
9312        let page = page.map_err(|error| match error {
9313            NativeSessionCatalogError::StaleCatalog => failure(
9314                NodeFailureCode::StaleNativeSessionCatalog,
9315                "native session catalog revision or cursor is stale",
9316            ),
9317            _ => failure(
9318                NodeFailureCode::BackendOperationFailed,
9319                "native session catalog page failed",
9320            ),
9321        })?;
9322        let entries = self.project_native_session_entries(&route, page.entries);
9323        Ok(crate::protocol::NativeSessionCatalogPage {
9324            window: page.window,
9325            revision: page.revision,
9326            entries,
9327            next_after_selection_id: page.next_after_selection_id,
9328            remaining_count: page.remaining_count,
9329            has_more: page.remaining_count > 0,
9330        })
9331    }
9332
9333    async fn preview_native_session(
9334        &self,
9335        selection: NativeSessionSelection,
9336        message_limit: u16,
9337    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9338        selection
9339            .validate()
9340            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9341        let canonical_root = self.native_session_route_root(&selection.route)?;
9342        let registered_workspace_roots = self.workspace_roots();
9343        let provider = selection.route.provider.clone();
9344        let scope = selection.route.scope;
9345        let catalog_revision = selection.catalog_revision;
9346        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9347        let selection_id = selection.selection_id.clone();
9348        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9349            failure(
9350                NodeFailureCode::InvalidRequest,
9351                "native session preview is not configured",
9352            )
9353        })?;
9354        let _permit = Arc::clone(&self.inspection_slots)
9355            .try_acquire_owned()
9356            .map_err(|_| {
9357                failure(
9358                    NodeFailureCode::BackendBusy,
9359                    "native session preview capacity is busy",
9360                )
9361            })?;
9362        let preview = run_native_session_catalog_operation(
9363            catalog,
9364            "native session preview",
9365            move |catalog| {
9366                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9367                    &provider,
9368                    scope,
9369                    canonical_root.as_deref().map(Path::new),
9370                    &registered_workspace_roots,
9371                    catalog_revision,
9372                    recent_cutoff_unix_ms,
9373                    &selection_id,
9374                    message_limit,
9375                ))
9376            },
9377        )
9378        .await?;
9379        preview
9380            .map(Into::into)
9381            .map_err(native_session_preview_failure)
9382    }
9383
9384    async fn index_native_session(
9385        &self,
9386        selection: NativeSessionSelection,
9387        display_name: String,
9388    ) -> Result<ManagedSessionRecord, NodeFailure> {
9389        selection
9390            .validate()
9391            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9392        if selection.route.scope
9393            != gate4agent_types::NativeSessionCatalogScope::Workspace
9394        {
9395            return Err(failure(
9396                NodeFailureCode::WorkspaceRegistrationRequired,
9397                "register the external project as a workspace before indexing or resuming it",
9398            ));
9399        }
9400        let workspace_id = selection
9401            .route
9402            .workspace_id
9403            .clone()
9404            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9405        let canonical_root = self
9406            .native_session_route_root(&selection.route)?
9407            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9408        let registered_workspace_roots = self.workspace_roots();
9409        let provider = selection.route.provider.clone();
9410        let catalog_revision = selection.catalog_revision;
9411        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9412        let selection_id = selection.selection_id.clone();
9413        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9414            failure(
9415                NodeFailureCode::InvalidRequest,
9416                "native session index is not configured",
9417            )
9418        })?;
9419        let _permit = Arc::clone(&self.inspection_slots)
9420            .try_acquire_owned()
9421            .map_err(|_| {
9422                failure(
9423                    NodeFailureCode::BackendBusy,
9424                    "native session index capacity is busy",
9425                )
9426            })?;
9427        let resolved = run_native_session_catalog_operation(
9428            catalog,
9429            "native session index resolution",
9430            move |catalog| {
9431                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9432                    &provider,
9433                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9434                    Some(Path::new(&canonical_root)),
9435                    &registered_workspace_roots,
9436                    catalog_revision,
9437                    recent_cutoff_unix_ms,
9438                    &selection_id,
9439                ))
9440            },
9441        )
9442        .await?
9443        .map_err(native_session_preview_failure)?;
9444        self.index_provider_session_with_policy(
9445            workspace_id,
9446            selection.route.provider,
9447            resolved.identity,
9448            display_name,
9449            true,
9450        )
9451    }
9452
9453    async fn preview_native_session_by_session_id(
9454        &self,
9455        workspace_id: WorkspaceId,
9456        provider: AgentId,
9457        session_id: String,
9458        message_limit: u16,
9459    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9460        let canonical_root = self.workspace_root(&workspace_id)?;
9461        let registered_workspace_roots = self.workspace_roots();
9462        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9463            failure(
9464                NodeFailureCode::InvalidRequest,
9465                "native session preview is not configured",
9466            )
9467        })?;
9468        let _permit = Arc::clone(&self.inspection_slots)
9469            .try_acquire_owned()
9470            .map_err(|_| {
9471                failure(
9472                    NodeFailureCode::BackendBusy,
9473                    "native session preview capacity is busy",
9474                )
9475            })?;
9476        run_native_session_catalog_operation(
9477            catalog,
9478            "native session preview",
9479            move |catalog| {
9480                catalog.preview_session_id_for_workspace(
9481                    &provider,
9482                    Path::new(&canonical_root),
9483                    &registered_workspace_roots,
9484                    &session_id,
9485                    message_limit,
9486                )
9487            },
9488        )
9489        .await
9490    }
9491
9492    async fn register_workspace(
9493        &self,
9494        workspace_id: WorkspaceId,
9495        root: String,
9496    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9497        validate_workspace_request_root(&root)?;
9498        let canonicalize_workspace_id = workspace_id.clone();
9499        let workspace = tokio::task::spawn_blocking(move || {
9500            WorkspaceConfig::new(canonicalize_workspace_id, root)
9501        })
9502        .await
9503        .map_err(|error| {
9504            failure(
9505                NodeFailureCode::BackendOperationFailed,
9506                &format!("workspace canonicalization task failed: {error}"),
9507            )
9508        })?
9509        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9510        let transaction = self
9511            .state_transaction
9512            .lock()
9513            .unwrap_or_else(|poisoned| poisoned.into_inner());
9514        let mut workspaces = self
9515            .workspaces
9516            .write()
9517            .unwrap_or_else(|poisoned| poisoned.into_inner());
9518        if workspaces.contains_key(&workspace_id) {
9519            return Err(failure(
9520                NodeFailureCode::DuplicateWorkspaceId,
9521                "workspace ID is already registered",
9522            ));
9523        }
9524        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9525            platform::roots_equal(existing_root, workspace.canonical_root())
9526        }) {
9527            return Err(failure(
9528                NodeFailureCode::DuplicateWorkspaceRoot,
9529                &format!("workspace root is already registered as '{existing_id}'"),
9530            ));
9531        }
9532        {
9533            let records = self
9534                .session_records
9535                .lock()
9536                .unwrap_or_else(|poisoned| poisoned.into_inner());
9537            if let Some(record) = records.records.values().find(|record| {
9538                record.workspace_id == workspace_id
9539                    && !record
9540                        .canonical_root
9541                        .as_utf8()
9542                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9543            }) {
9544                return Err(failure(
9545                    NodeFailureCode::SessionRecordConflict,
9546                    &format!(
9547                        "workspace ID is retained by session record '{}' at another root",
9548                        record.record_id,
9549                    ),
9550                ));
9551            }
9552        }
9553        let snapshot = WorkspaceSnapshot {
9554            workspace_id: workspace_id.clone(),
9555            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9556            sessions: Vec::new(),
9557            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9558            managed_worktree_profiles: Some(WorktreeProfileInventory {
9559                profiles: Vec::new(),
9560            }),
9561        };
9562        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9563        drop(workspaces);
9564        let previous_records = {
9565            let mut records = self
9566                .session_records
9567                .lock()
9568                .unwrap_or_else(|poisoned| poisoned.into_inner());
9569            let previous = records.records.clone();
9570            for record in records.records.values_mut().filter(|record| {
9571                record.workspace_id == workspace_id
9572                    && record
9573                        .canonical_root
9574                        .as_utf8()
9575                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9576            }) {
9577                if record.active_session.is_none() {
9578                    record.state = detached_record_state(record);
9579                }
9580                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9581                    record.last_error = None;
9582                }
9583                record.updated_at_unix_ms = unix_time_ms();
9584            }
9585            previous
9586        };
9587        if let Err(error) = self.persist_state_locked() {
9588            self.workspaces
9589                .write()
9590                .unwrap_or_else(|poisoned| poisoned.into_inner())
9591                .remove(&workspace_id);
9592            self.session_records
9593                .lock()
9594                .unwrap_or_else(|poisoned| poisoned.into_inner())
9595                .records = previous_records;
9596            return Err(persistence_failure(error));
9597        }
9598        let restored = self
9599            .session_records
9600            .lock()
9601            .unwrap_or_else(|poisoned| poisoned.into_inner())
9602            .records
9603            .values()
9604            .filter(|record| record.workspace_id == workspace_id)
9605            .cloned()
9606            .collect::<Vec<_>>();
9607        drop(transaction);
9608        self.publish(NodeEvent::WorkspaceAdded {
9609            workspace: snapshot.clone(),
9610        });
9611        for record in restored {
9612            self.publish_record(record);
9613        }
9614        Ok(snapshot)
9615    }
9616
9617    async fn create_standalone_workspace(
9618        &self,
9619        workspace_id: WorkspaceId,
9620        root: String,
9621        initial_branch: Option<String>,
9622    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9623        validate_workspace_request_root(&root)?;
9624        validate_git_revision("initial branch", initial_branch.as_deref())?;
9625        let preflight_workspace_id = workspace_id.clone();
9626        let preflight_root = root.clone();
9627        let canonical_candidate = tokio::task::spawn_blocking(move || {
9628            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9629        })
9630        .await
9631        .map_err(|_| failure(
9632            NodeFailureCode::BackendOperationFailed,
9633            "standalone workspace root preflight task failed",
9634        ))?
9635        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9636        {
9637            let workspaces = self
9638                .workspaces
9639                .read()
9640                .unwrap_or_else(|poisoned| poisoned.into_inner());
9641            if workspaces.contains_key(&workspace_id) {
9642                return Err(failure(
9643                    NodeFailureCode::DuplicateWorkspaceId,
9644                    "workspace ID is already registered",
9645                ));
9646            }
9647            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9648                platform::roots_equal(existing_root, &canonical_candidate)
9649            }) {
9650                return Err(failure(
9651                    NodeFailureCode::DuplicateWorkspaceRoot,
9652                    &format!("workspace root is already registered as '{existing_id}'"),
9653                ));
9654            }
9655        }
9656        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9657            .await
9658            .map_err(standalone_workspace_failure)?;
9659        prepared
9660            .verify_for_registration()
9661            .await
9662            .map_err(standalone_workspace_failure)?;
9663        match self
9664            .register_workspace(workspace_id, prepared.root().to_owned())
9665            .await
9666        {
9667            Ok(workspace) => Ok(workspace),
9668            Err(registration_error) => {
9669                if prepared.compensate_registration_failure().await {
9670                    Err(registration_error)
9671                } else {
9672                    Err(failure(
9673                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9674                        "standalone workspace initialization succeeded but registration failed; recovery is required",
9675                    ))
9676                }
9677            }
9678        }
9679    }
9680
9681    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9682        let bindings = self
9683            .session_bindings
9684            .lock()
9685            .unwrap_or_else(|poisoned| poisoned.into_inner());
9686        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9687            return Err(failure(
9688                NodeFailureCode::WorkspaceBusy,
9689                "workspace retains a bound session; remove every session before unregistering it",
9690            ));
9691        }
9692        drop(bindings);
9693        let transaction = self
9694            .state_transaction
9695            .lock()
9696            .unwrap_or_else(|poisoned| poisoned.into_inner());
9697        let mut workspaces = self
9698            .workspaces
9699            .write()
9700            .unwrap_or_else(|poisoned| poisoned.into_inner());
9701        if !workspaces.contains_key(workspace_id) {
9702            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9703        }
9704        if workspaces.len() == 1 {
9705            return Err(failure(
9706                NodeFailureCode::LastWorkspace,
9707                "node must retain at least one workspace",
9708            ));
9709        }
9710        let removed_root = workspaces
9711            .remove(workspace_id)
9712            .expect("workspace presence was validated");
9713        drop(workspaces);
9714        let previous_records = {
9715            let mut records = self
9716                .session_records
9717                .lock()
9718                .unwrap_or_else(|poisoned| poisoned.into_inner());
9719            let previous = records.records.clone();
9720            for record in records
9721                .records
9722                .values_mut()
9723                .filter(|record| &record.workspace_id == workspace_id)
9724            {
9725                record.active_session = None;
9726                record.state = ManagedSessionState::Unavailable;
9727                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9728                record.updated_at_unix_ms = unix_time_ms();
9729            }
9730            previous
9731        };
9732        if let Err(error) = self.persist_state_locked() {
9733            self.workspaces
9734                .write()
9735                .unwrap_or_else(|poisoned| poisoned.into_inner())
9736                .insert(workspace_id.clone(), removed_root);
9737            self.session_records
9738                .lock()
9739                .unwrap_or_else(|poisoned| poisoned.into_inner())
9740                .records = previous_records;
9741            return Err(persistence_failure(error));
9742        }
9743        let unavailable = self
9744            .session_records
9745            .lock()
9746            .unwrap_or_else(|poisoned| poisoned.into_inner())
9747            .records
9748            .values()
9749            .filter(|record| &record.workspace_id == workspace_id)
9750            .cloned()
9751            .collect::<Vec<_>>();
9752        drop(transaction);
9753        self.publish(NodeEvent::WorkspaceRemoved {
9754            workspace_id: workspace_id.clone(),
9755        });
9756        for record in unavailable {
9757            self.publish_record(record);
9758        }
9759        Ok(())
9760    }
9761
9762    async fn create_worktree(
9763        &self,
9764        source_workspace_id: WorkspaceId,
9765        workspace_id: WorkspaceId,
9766        target_root: String,
9767        branch: String,
9768        base: Option<String>,
9769    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9770        self.require_worktree_service(&source_workspace_id)?;
9771        validate_workspace_request_root(&target_root)?;
9772        validate_git_revision("worktree base", base.as_deref())?;
9773        let source_root = self.workspace_root(&source_workspace_id)?;
9774        {
9775            let workspaces = self
9776                .workspaces
9777                .read()
9778                .unwrap_or_else(|poisoned| poisoned.into_inner());
9779            if workspaces.contains_key(&workspace_id) {
9780                return Err(failure(
9781                    NodeFailureCode::DuplicateWorkspaceId,
9782                    "workspace ID is already registered",
9783                ));
9784            }
9785            if let Some((existing_id, _)) = workspaces
9786                .iter()
9787                .find(|(_, root)| worktree_paths_equal(root, &target_root))
9788            {
9789                return Err(failure(
9790                    NodeFailureCode::DuplicateWorkspaceRoot,
9791                    &format!("workspace root is already registered as '{existing_id}'"),
9792                ));
9793            }
9794        }
9795        let mut worktree = create_git_worktree(
9796            &source_root,
9797            &target_root,
9798            &branch,
9799            base.as_deref(),
9800        )
9801        .await
9802        .map_err(git_worktree_failure)?;
9803        let workspace = match self
9804            .register_workspace(workspace_id, worktree.path.clone())
9805            .await
9806        {
9807            Ok(workspace) => workspace,
9808            Err(registration_error) => {
9809                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9810                    return Err(failure(
9811                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
9812                        "worktree registration failed and compensation requires recovery",
9813                    ));
9814                }
9815                return Err(failure(
9816                    registration_error.code,
9817                    &registration_error.message,
9818                ));
9819            }
9820        };
9821        worktree.workspace_id = Some(workspace.workspace_id.clone());
9822        Ok((protocol_worktree(worktree), workspace))
9823    }
9824
9825    async fn remove_worktree(
9826        &self,
9827        source_workspace_id: WorkspaceId,
9828        target_root: String,
9829    ) -> Result<Option<WorkspaceId>, NodeFailure> {
9830        self.require_worktree_service(&source_workspace_id)?;
9831        validate_workspace_request_root(&target_root)?;
9832        let source_root = self.workspace_root(&source_workspace_id)?;
9833        let target_root = normalize_worktree_removal_target(&target_root)
9834            .map_err(git_worktree_failure)?;
9835        let target = list_git_worktrees(&source_root)
9836            .await
9837            .map_err(git_worktree_failure)?
9838            .into_iter()
9839            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9840            .ok_or_else(|| {
9841                failure(
9842                    NodeFailureCode::WorktreeProtected,
9843                    "refusing to remove a path that is not in Git's worktree listing",
9844                )
9845            })?;
9846        let registered_workspace_id = {
9847            let workspaces = self
9848                .workspaces
9849                .read()
9850                .unwrap_or_else(|poisoned| poisoned.into_inner());
9851            let matched = workspaces
9852                .iter()
9853                .find(|(_, root)| worktree_paths_equal(root, &target.path))
9854                .map(|(workspace_id, _)| workspace_id.clone());
9855            if matched.is_some() && workspaces.len() == 1 {
9856                return Err(failure(
9857                    NodeFailureCode::LastWorkspace,
9858                    "node must retain at least one workspace",
9859                ));
9860            }
9861            matched
9862        };
9863        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9864            let bindings = self
9865                .session_bindings
9866                .lock()
9867                .unwrap_or_else(|poisoned| poisoned.into_inner());
9868            if bindings
9869                .values()
9870                .any(|binding| &binding.workspace_id == workspace_id)
9871            {
9872                return Err(failure(
9873                    NodeFailureCode::WorkspaceBusy,
9874                    "worktree workspace retains a bound session; remove every session first",
9875                ));
9876            }
9877        }
9878        remove_git_worktree(&source_root, &target.path)
9879            .await
9880            .map_err(git_worktree_failure)?;
9881        if let Some(workspace_id) = registered_workspace_id.as_ref() {
9882            self.unregister_workspace(workspace_id)
9883                .map_err(|error| failure(error.code, &error.message))?;
9884        }
9885        Ok(registered_workspace_id)
9886    }
9887
9888    async fn cleanup_managed_worktree(
9889        &self,
9890        lease_id: &ManagedWorktreeLeaseId,
9891        explicit: bool,
9892    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9893        let lease = self.managed_worktrees.lock()
9894            .unwrap_or_else(|poisoned| poisoned.into_inner())
9895            .get(lease_id)
9896            .cloned()
9897            .ok_or_else(|| failure(
9898                NodeFailureCode::UnknownManagedWorktreeLease,
9899                "managed worktree lease does not exist",
9900            ))?;
9901        if lease.state == ManagedWorktreeLeaseState::Removed {
9902            return Ok(lease.snapshot());
9903        }
9904        if lease.has_holders() {
9905            return Err(failure(
9906                NodeFailureCode::ManagedWorktreeBusy,
9907                "managed worktree lease still has a session or durable-record holder",
9908            ));
9909        }
9910        if self
9911            .materializations
9912            .lock()
9913            .unwrap_or_else(|poisoned| poisoned.into_inner())
9914            .values()
9915            .any(|record| record.managed_lease_id() == Some(lease_id))
9916        {
9917            return Err(failure(
9918                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9919                "managed worktree cleanup is blocked by session environment ownership",
9920            ));
9921        }
9922        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9923            let snapshot = {
9924                let mut registry = self.managed_worktrees.lock()
9925                    .unwrap_or_else(|poisoned| poisoned.into_inner());
9926                let current = registry.get_mut(lease_id)
9927                    .expect("managed worktree lease remains present");
9928                current.state = ManagedWorktreeLeaseState::Retained;
9929                current.cleanup_failure = None;
9930                current.updated_at_unix_ms = unix_time_ms();
9931                current.snapshot()
9932            };
9933            if self.persist_state().is_err() {
9934                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9935            }
9936            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9937            return Ok(snapshot);
9938        }
9939        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9940        if profile.revision() != &lease.profile_revision {
9941            self.mark_managed_recovery_required(
9942                lease_id,
9943                ManagedWorktreeCleanupFailure::OwnershipConflict,
9944            );
9945            return Err(failure(
9946                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9947                "managed worktree profile revision changed",
9948            ));
9949        }
9950        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9951            self.mark_managed_recovery_required(
9952                lease_id,
9953                ManagedWorktreeCleanupFailure::OwnershipConflict,
9954            );
9955            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9956        })?;
9957        let source_root = self.workspace_root(&lease.source_workspace_id)?;
9958        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9959            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9960            managed_git_worktree_failure(error)
9961        })?;
9962        let target = listed.iter()
9963            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9964        if let Some(target) = target {
9965            if target.is_main || target.is_bare || !exact_owned_worktree(
9966                &lease,
9967                &target.path,
9968                target.branch.as_deref(),
9969                &target.head,
9970            ) {
9971                self.mark_managed_recovery_required(
9972                    lease_id,
9973                    ManagedWorktreeCleanupFailure::OwnershipConflict,
9974                );
9975                return Err(failure(
9976                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
9977                    "Git worktree identity no longer matches the managed lease",
9978                ));
9979            }
9980            if target.prunable {
9981                self.mark_managed_cleanup_blocked(
9982                    lease_id,
9983                    ManagedWorktreeCleanupFailure::Prunable,
9984                );
9985                return Err(failure(
9986                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
9987                    "managed worktree is prunable and requires recovery",
9988                ));
9989            }
9990            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9991                let failure_kind = match error.kind {
9992                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9993                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9994                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9995                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9996                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9997                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9998                };
9999                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10000                    self.mark_managed_recovery_required(lease_id, failure_kind);
10001                } else {
10002                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10003                }
10004                return Err(managed_git_worktree_failure(error));
10005            }
10006        } else {
10007            match std::fs::symlink_metadata(&lease.target_root) {
10008                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10009                Ok(_) => {
10010                    self.mark_managed_recovery_required(
10011                        lease_id,
10012                        ManagedWorktreeCleanupFailure::OwnershipConflict,
10013                    );
10014                    return Err(failure(
10015                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
10016                        "managed target exists but is not owned by Git",
10017                    ));
10018                }
10019                Err(_) => {
10020                    self.mark_managed_cleanup_blocked(
10021                        lease_id,
10022                        ManagedWorktreeCleanupFailure::Backend,
10023                    );
10024                    return Err(failure(
10025                        NodeFailureCode::BackendOperationFailed,
10026                        "managed target identity could not be inspected",
10027                    ));
10028                }
10029            }
10030        }
10031        let registered = self.workspaces.read()
10032            .unwrap_or_else(|poisoned| poisoned.into_inner())
10033            .contains_key(&lease.workspace_id);
10034        if registered {
10035            self.unregister_workspace(&lease.workspace_id)?;
10036        }
10037        let removed = {
10038            self.managed_worktrees.lock()
10039                .unwrap_or_else(|poisoned| poisoned.into_inner())
10040                .tombstone(lease_id, unix_time_ms())
10041                .expect("managed worktree lease remains present during cleanup")
10042        };
10043        self.persist_state().map_err(persistence_failure)?;
10044        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10045        Ok(removed.snapshot())
10046    }
10047
10048    fn mark_managed_cleanup_blocked(
10049        &self,
10050        lease_id: &ManagedWorktreeLeaseId,
10051        failure_kind: ManagedWorktreeCleanupFailure,
10052    ) {
10053        let snapshot = {
10054            let mut registry = self.managed_worktrees.lock()
10055                .unwrap_or_else(|poisoned| poisoned.into_inner());
10056            let Some(lease) = registry.get_mut(lease_id) else { return };
10057            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10058            lease.cleanup_failure = Some(failure_kind);
10059            lease.updated_at_unix_ms = unix_time_ms();
10060            lease.snapshot()
10061        };
10062        let _ = self.persist_state();
10063        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10064    }
10065
10066    fn require_worktree_service(
10067        &self,
10068        source_workspace_id: &WorkspaceId,
10069    ) -> Result<(), NodeFailure> {
10070        if self.managed_worktrees.lock()
10071            .unwrap_or_else(|poisoned| poisoned.into_inner())
10072            .lease_for_workspace(source_workspace_id)
10073            .is_some()
10074        {
10075            return Err(failure(
10076                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10077                "managed worktree targets are internal-only workspaces",
10078            ));
10079        }
10080        match self
10081            .worktree_service_modes
10082            .get(source_workspace_id)
10083            .copied()
10084            .unwrap_or(WorktreeServiceMode::Manual)
10085        {
10086            WorktreeServiceMode::Manual => Ok(()),
10087            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10088                NodeFailureCode::UnsupportedSpawnCapability,
10089                "Git worktree service mode is unavailable in this node build",
10090            )),
10091        }
10092    }
10093
10094    fn managed_reservation_conflict(
10095        &self,
10096        workspace_id: Option<&WorkspaceId>,
10097        root: Option<&str>,
10098    ) -> bool {
10099        self.managed_worktrees.lock()
10100            .unwrap_or_else(|poisoned| poisoned.into_inner())
10101            .active_records()
10102            .any(|lease| {
10103                workspace_id.is_some_and(|workspace_id| {
10104                    workspace_id == &lease.source_workspace_id
10105                        || workspace_id == &lease.workspace_id
10106                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10107            })
10108    }
10109
10110    fn reject_managed_reservation(
10111        &self,
10112        workspace_id: Option<&WorkspaceId>,
10113        root: Option<&str>,
10114    ) -> Result<(), NodeFailure> {
10115        if self.managed_reservation_conflict(workspace_id, root) {
10116            Err(failure(
10117                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10118                "managed worktree ownership may only be mutated through its lease cleanup",
10119            ))
10120        } else {
10121            Ok(())
10122        }
10123    }
10124
10125    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10126        self.validate_address(address)?;
10127        self.workspace_root(&address.workspace_id)
10128    }
10129
10130    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
10131    /// `require_policy`'s own doc on those two requirements), so they keep
10132    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
10133    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
10134    /// `false` on the `require_policy` call below for them because a PTY
10135    /// resume never needs those facts, not because this method cannot look
10136    /// them up.
10137    ///
10138    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
10139    /// the SAME `binding.runtime_policy` every other requirement reads here
10140    /// (one computed policy, read by every gate). That policy is no longer
10141    /// PTY-terminal-verification-shaped for an ACP session --
10142    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
10143    /// `provider_runtime::policy_for_transport` -- so consulting it for
10144    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
10145    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
10146    /// even though a prompt is an ACP session's only interface). Pipe keeps
10147    /// reading the same PTY-probe-derived value it always has --
10148    /// `policy_for_transport` passes Pipe through unchanged this pass (see
10149    /// its own doc for why).
10150    ///
10151    /// What this method still adds on top, for ACP/Pipe only, is a
10152    /// turn-in-flight PRECONDITION ahead of that policy check: a session
10153    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
10154    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
10155    /// read off the same live `SessionSnapshot` state `require_acp_session`
10156    /// already reads for the ACP control verbs -- `TurnStarted` observed
10157    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
10158    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
10159    /// its fate in vendor-specific behaviour this node cannot observe (one
10160    /// agent may queue it, another drop it, another interleave it), so this
10161    /// refuses it outright rather than gambling on that -- a rule with no
10162    /// PTY analogue, which is why PTY does not take this extra precondition.
10163    fn require_session_runtime_policy(
10164        &self,
10165        address: &SessionAddress,
10166        requirement: ProviderRuntimeRequirement,
10167    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10168        let runtime_policy = {
10169            let bindings = self
10170                .session_bindings
10171                .lock()
10172                .unwrap_or_else(|poisoned| poisoned.into_inner());
10173            let binding = bindings
10174                .get(&address.session.instance_id)
10175                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10176            if binding.workspace_id != address.workspace_id {
10177                return Err(failure(
10178                    NodeFailureCode::SessionWorkspaceMismatch,
10179                    "session belongs to another workspace",
10180                ));
10181            }
10182            if binding.generation != address.session.generation {
10183                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10184            }
10185            binding.runtime_policy
10186        };
10187        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10188            let snapshot = self.internal_session_snapshot(address)?;
10189            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10190                && snapshot.provider.lead_activity != ProviderActivity::Idle
10191            {
10192                return Err(failure(
10193                    NodeFailureCode::TurnInFlight,
10194                    "session already has a turn in flight; wait for it to complete before sending another prompt",
10195                ));
10196            }
10197        }
10198        require_policy(runtime_policy, false, false, requirement)
10199            .map(|()| runtime_policy)
10200            .map_err(|_| failure(
10201                NodeFailureCode::UnsupportedCapability,
10202                "session runtime policy does not admit this semantic operation",
10203            ))
10204    }
10205
10206    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10207        let mut bindings = self
10208            .session_bindings
10209            .lock()
10210            .unwrap_or_else(|poisoned| poisoned.into_inner());
10211        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10212            binding.workspace_id == address.workspace_id
10213                && binding.generation == address.session.generation
10214        }) {
10215            let removed = bindings.remove(&address.session.instance_id);
10216            self.clear_terminal_frame_watermark(address);
10217            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10218            return removed;
10219        }
10220        None
10221    }
10222
10223    fn cleanup_session_owned_materialization(
10224        &self,
10225        address: &SessionAddress,
10226        binding: &SessionBinding,
10227    ) -> Result<(), NodeFailure> {
10228        let Some(id) = binding.materialization_id.as_ref() else {
10229            return Ok(());
10230        };
10231        let owner = self
10232            .materializations
10233            .lock()
10234            .unwrap_or_else(|poisoned| poisoned.into_inner())
10235            .get(id)
10236            .map(|record| record.owner().clone());
10237        match owner {
10238            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10239            Some(MaterializationOwner::Session {
10240                incarnation_id,
10241                instance_id,
10242                generation,
10243            }) if incarnation_id == self.incarnation_id
10244                && instance_id == address.session.instance_id
10245                && generation == address.session.generation =>
10246            {
10247                self.cleanup_materialization(id)
10248            }
10249            Some(MaterializationOwner::Session { .. }) => Err(failure(
10250                NodeFailureCode::BackendOperationFailed,
10251                "session environment ownership requires recovery",
10252            )),
10253        }
10254    }
10255
10256    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10257        let mut watermarks = self
10258            .terminal_frame_watermarks
10259            .lock()
10260            .unwrap_or_else(|poisoned| poisoned.into_inner());
10261        if watermarks
10262            .get(&address.session.instance_id)
10263            .is_some_and(|(current, _)| current == address)
10264        {
10265            watermarks.remove(&address.session.instance_id);
10266        }
10267    }
10268
10269    #[cfg(test)]
10270    fn address_for(
10271        &self,
10272        instance_id: AgentInstanceId,
10273        generation: SessionGeneration,
10274    ) -> Option<SessionAddress> {
10275        let binding = self.session_bindings
10276            .lock()
10277            .unwrap_or_else(|poisoned| poisoned.into_inner())
10278            .get(&instance_id)
10279            .cloned()?;
10280        (binding.generation == generation).then_some(SessionAddress {
10281            workspace_id: binding.workspace_id,
10282            session: SessionKey { instance_id, generation },
10283        })
10284    }
10285
10286    fn arm_resume(
10287        &self,
10288        address: &SessionAddress,
10289        command_id: CommandId,
10290        runtime_policy: ProviderRuntimePolicy,
10291    ) -> Result<(), NodeFailure> {
10292        let mut bindings = self
10293            .session_bindings
10294            .lock()
10295            .unwrap_or_else(|poisoned| poisoned.into_inner());
10296        let binding = bindings
10297            .get_mut(&address.session.instance_id)
10298            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10299        if binding.workspace_id != address.workspace_id {
10300            return Err(failure(
10301                NodeFailureCode::SessionWorkspaceMismatch,
10302                "session belongs to another workspace",
10303            ));
10304        }
10305        if binding.generation != address.session.generation {
10306            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10307        }
10308        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10309        Ok(())
10310    }
10311
10312    fn clear_armed_resume(&self, address: &SessionAddress) {
10313        let mut bindings = self
10314            .session_bindings
10315            .lock()
10316            .unwrap_or_else(|poisoned| poisoned.into_inner());
10317        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10318            if binding.workspace_id == address.workspace_id
10319                && binding.generation == address.session.generation
10320            {
10321                binding.pending_resume = None;
10322            }
10323        }
10324    }
10325
10326    fn publish_control(&self, event: ControlEvent) {
10327        let revokes_harness_mcp = matches!(
10328            &event.event,
10329            ControlEventKind::Exited { .. }
10330                | ControlEventKind::Failed { .. }
10331                | ControlEventKind::Removed
10332        );
10333        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10334            let mut bindings = self
10335                .session_bindings
10336                .lock()
10337                .unwrap_or_else(|poisoned| poisoned.into_inner());
10338            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10339                return;
10340            };
10341            let previous_address = SessionAddress {
10342                workspace_id: binding.workspace_id.clone(),
10343                session: SessionKey {
10344                    instance_id: event.instance_id,
10345                    generation: binding.generation,
10346                },
10347            };
10348            let mut resume_command_rejected = false;
10349            if binding.generation == event.generation {
10350                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10351                    let rejected_resume = matches!(
10352                        &event.event,
10353                        ControlEventKind::CommandRejected { .. }
10354                    ) && event.command_id == Some(pending_command_id);
10355                    let resume_failed = matches!(
10356                        &event.event,
10357                        ControlEventKind::ResumeDenied { .. }
10358                            | ControlEventKind::ResumeFailed { .. }
10359                    );
10360                    if pending_generation == binding.generation
10361                        && (rejected_resume || resume_failed)
10362                    {
10363                        resume_command_rejected = rejected_resume;
10364                        binding.pending_resume = None;
10365                    }
10366                }
10367            } else {
10368                let expected = binding.generation.0.checked_add(1);
10369                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10370                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10371                    generation == binding.generation
10372                })
10373                    || expected != Some(event.generation.0)
10374                    || !authorized
10375                {
10376                    return;
10377                }
10378                let (_, _, runtime_policy) = binding
10379                    .pending_resume
10380                    .expect("authorized resume retains its pending runtime policy");
10381                binding.generation = event.generation;
10382                binding.runtime_policy = runtime_policy;
10383                binding.pending_resume = None;
10384            }
10385            (
10386                SessionAddress {
10387                    workspace_id: binding.workspace_id.clone(),
10388                    session: SessionKey {
10389                        instance_id: event.instance_id,
10390                        generation: event.generation,
10391                    },
10392                },
10393                (previous_address.session.generation != event.generation)
10394                    .then_some(previous_address),
10395                binding.record_id.clone(),
10396                binding.runtime_policy.provider_session_identity,
10397                resume_command_rejected,
10398            )
10399        };
10400        if let Some(previous_address) = previous_address.as_ref() {
10401            self.clear_terminal_frame_watermark(previous_address);
10402        }
10403        if let Some(record_id) = record_id {
10404            self.reconcile_managed_record(
10405                &record_id,
10406                &address,
10407                &event,
10408                identity_admitted,
10409                resume_command_rejected,
10410            );
10411        }
10412        // Every state change a session's process lifecycle can reach gets a
10413        // line here: this is the single chokepoint every `ControlEventKind`
10414        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10415        // came from a fresh spawn or a resume. Per-message provider chatter
10416        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10417        // matched here -- that is exactly the per-frame noise this crate
10418        // family's level discipline forbids at INFO.
10419        match &event.event {
10420            ControlEventKind::Running { process_id } => {
10421                #[cfg(unix)]
10422                if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10423                    proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10424                }
10425                tracing::info!(
10426                    node_id = %self.node_id,
10427                    workspace_id = %address.workspace_id,
10428                    session = ?address.session,
10429                    pid = ?process_id,
10430                    "session process is running",
10431                );
10432            }
10433            ControlEventKind::Exited { exit_code, forced } => {
10434                #[cfg(unix)]
10435                if let Some(proxy) = self.signer_proxy.get().cloned() {
10436                    let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10437                    if proxy.has_session(&key) {
10438                        // Revoke at core off the event path (blocking uplink call).
10439                        std::thread::spawn(move || proxy.release(&key));
10440                    }
10441                }
10442                tracing::info!(
10443                    node_id = %self.node_id,
10444                    workspace_id = %address.workspace_id,
10445                    session = ?address.session,
10446                    exit_code = ?exit_code,
10447                    forced = forced,
10448                    "session process exited",
10449                );
10450            }
10451            ControlEventKind::Failed { message } => {
10452                tracing::warn!(
10453                    node_id = %self.node_id,
10454                    workspace_id = %address.workspace_id,
10455                    session = ?address.session,
10456                    cause = %message,
10457                    "session failed",
10458                );
10459            }
10460            ControlEventKind::CommandRejected { message } => {
10461                tracing::warn!(
10462                    node_id = %self.node_id,
10463                    workspace_id = %address.workspace_id,
10464                    session = ?address.session,
10465                    cause = %message,
10466                    "session command rejected",
10467                );
10468            }
10469            ControlEventKind::Removed => {
10470                tracing::info!(
10471                    node_id = %self.node_id,
10472                    workspace_id = %address.workspace_id,
10473                    session = ?address.session,
10474                    "session removed",
10475                );
10476            }
10477            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10478                tracing::info!(
10479                    node_id = %self.node_id,
10480                    workspace_id = %address.workspace_id,
10481                    session = ?address.session,
10482                    provider_session = ?provider_session,
10483                    "session resume authorized",
10484                );
10485            }
10486            ControlEventKind::Resumed { session: provider_session, process_id } => {
10487                tracing::info!(
10488                    node_id = %self.node_id,
10489                    workspace_id = %address.workspace_id,
10490                    session = ?address.session,
10491                    provider_session = ?provider_session,
10492                    pid = ?process_id,
10493                    "session resumed",
10494                );
10495            }
10496            ControlEventKind::ResumeDenied { reason } => {
10497                tracing::warn!(
10498                    node_id = %self.node_id,
10499                    workspace_id = %address.workspace_id,
10500                    session = ?address.session,
10501                    cause = %reason,
10502                    "session resume denied",
10503                );
10504            }
10505            ControlEventKind::ResumeFailed { message } => {
10506                tracing::warn!(
10507                    node_id = %self.node_id,
10508                    workspace_id = %address.workspace_id,
10509                    session = ?address.session,
10510                    cause = %message,
10511                    "session resume failed",
10512                );
10513            }
10514            _ => {}
10515        }
10516        self.apply_pending_acp_options(&address, &event);
10517        let stream_chunk = agent_stream_chunk(&event);
10518        self.publish(NodeEvent::Control {
10519            address: address.clone(),
10520            event,
10521        });
10522        if let Some(chunk) = stream_chunk {
10523            self.publish_agent_stream_chunk(address.clone(), chunk);
10524        }
10525        if revokes_harness_mcp {
10526            SessionLifecycleHook::on_session_ended(self, &address);
10527        }
10528    }
10529
10530    fn reconcile_managed_record(
10531        &self,
10532        record_id: &SessionRecordId,
10533        address: &SessionAddress,
10534        event: &ControlEvent,
10535        identity_admitted: bool,
10536        resume_command_rejected: bool,
10537    ) {
10538        let _transaction = self
10539            .state_transaction
10540            .lock()
10541            .unwrap_or_else(|poisoned| poisoned.into_inner());
10542        let observed_identity = match &event.event {
10543            ControlEventKind::ProviderEvent {
10544                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10545                ..
10546            } if identity_admitted => Some(identity.clone()),
10547            _ => None,
10548        };
10549        let identity_was_observed = observed_identity.is_some();
10550        let replacement_id = observed_identity
10551            .as_ref()
10552            .and_then(|_| self.allocate_record_id().ok());
10553        let mut upserts = Vec::new();
10554        let mut removals = Vec::new();
10555        let mut rebind = None;
10556        let original_records;
10557        let original_materializations = self
10558            .materializations
10559            .lock()
10560            .unwrap_or_else(|poisoned| poisoned.into_inner())
10561            .clone();
10562        {
10563            let mut records = self
10564                .session_records
10565                .lock()
10566                .unwrap_or_else(|poisoned| poisoned.into_inner());
10567            original_records = records.records.clone();
10568            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10569                return;
10570            };
10571            if let Some(identity) = observed_identity {
10572                let existing_id = records
10573                    .records
10574                    .values()
10575                    .find(|record| {
10576                        record.record_id != *record_id
10577                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10578                                session_registry::same_provider_session(
10579                                    &record.provider,
10580                                    record_identity,
10581                                    &current_snapshot.provider,
10582                                    &identity,
10583                                )
10584                            })
10585                    })
10586                    .map(|record| record.record_id.clone());
10587                if let Some(existing_id) = existing_id {
10588                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10589                        record.mode == current_snapshot.mode
10590                            && record.workspace_id == current_snapshot.workspace_id
10591                            && record.environment_profile == current_snapshot.environment_profile
10592                            && record.bundle == current_snapshot.bundle
10593                            && record.context_id == current_snapshot.context_id
10594                            && record.context == current_snapshot.context
10595                            && record
10596                                .canonical_root
10597                                .as_utf8()
10598                                .zip(current_snapshot.canonical_root.as_utf8())
10599                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
10600                    });
10601                    if !scope_matches {
10602                        if let Some(current) = records.records.get_mut(record_id) {
10603                            current.state = ManagedSessionState::Unavailable;
10604                            current.last_error =
10605                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10606                            current.updated_at_unix_ms = unix_time_ms();
10607                            upserts.push(current.clone());
10608                        }
10609                    } else {
10610                        let existing_is_active = records
10611                            .records
10612                            .get(&existing_id)
10613                            .is_some_and(|record| record.active_session.is_some());
10614                        if existing_is_active {
10615                            if let Some(current) = records.records.get_mut(record_id) {
10616                                current.state = ManagedSessionState::Unavailable;
10617                                current.last_error =
10618                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10619                                current.updated_at_unix_ms = unix_time_ms();
10620                                upserts.push(current.clone());
10621                            }
10622                        } else {
10623                            if current_snapshot.provider_session.is_some() {
10624                                if let Some(current) = records.records.get_mut(record_id) {
10625                                    current.active_session = None;
10626                                    current.state = ManagedSessionState::Dormant;
10627                                    current.updated_at_unix_ms = unix_time_ms();
10628                                    upserts.push(current.clone());
10629                                }
10630                            } else {
10631                                records.records.remove(record_id);
10632                                removals.push(record_id.clone());
10633                            }
10634                            if let Some(existing) = records.records.get_mut(&existing_id) {
10635                                existing.provider_session = Some(identity.clone());
10636                                existing.active_session = Some(address.clone());
10637                                existing.state = ManagedSessionState::Live;
10638                                existing.updated_at_unix_ms = unix_time_ms();
10639                                existing.last_error = None;
10640                                upserts.push(existing.clone());
10641                            }
10642                            rebind = Some(existing_id);
10643                        }
10644                    }
10645                } else if current_snapshot
10646                    .provider_session
10647                    .as_ref()
10648                    .is_some_and(|current| {
10649                        !session_registry::same_provider_session(
10650                            &current_snapshot.provider,
10651                            current,
10652                            &current_snapshot.provider,
10653                            &identity,
10654                        )
10655                    })
10656                {
10657                    if let Some(current) = records.records.get_mut(record_id) {
10658                        current.active_session = None;
10659                        if current.bundle.is_some() || current.context.is_some() {
10660                            let had_bundle = current.bundle.is_some();
10661                            current.provider_session = None;
10662                            current.bundle = None;
10663                            current.context_id = None;
10664                            current.context = None;
10665                            current.state = ManagedSessionState::Unavailable;
10666                            current.last_error = Some(if had_bundle {
10667                                "bundle-unavailable".to_owned()
10668                            } else {
10669                                "context-unavailable".to_owned()
10670                            });
10671                        } else {
10672                            current.state = ManagedSessionState::Dormant;
10673                        }
10674                        current.updated_at_unix_ms = unix_time_ms();
10675                        upserts.push(current.clone());
10676                    }
10677                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10678                        if let Some(current) = records.records.get_mut(record_id) {
10679                            current.state = ManagedSessionState::Unavailable;
10680                            current.last_error =
10681                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10682                            current.updated_at_unix_ms = unix_time_ms();
10683                            upserts.push(current.clone());
10684                        }
10685                    } else if let Some(new_record_id) = replacement_id {
10686                        let now = unix_time_ms();
10687                        let replacement = ManagedSessionRecord {
10688                            record_id: new_record_id.clone(),
10689                            display_name: format!(
10690                                "{} #{}",
10691                                current_snapshot.provider.as_str(),
10692                                records
10693                                    .records
10694                                    .values()
10695                                    .filter(|record| {
10696                                        record.provider == current_snapshot.provider
10697                                    })
10698                                    .count()
10699                                    .saturating_add(1),
10700                            ),
10701                            provider: current_snapshot.provider.clone(),
10702                            mode: current_snapshot.mode,
10703                            state: ManagedSessionState::Live,
10704                            workspace_id: current_snapshot.workspace_id,
10705                            canonical_root: current_snapshot.canonical_root,
10706                            provider_session: Some(identity),
10707                            active_session: Some(address.clone()),
10708                            environment_profile: current_snapshot.environment_profile,
10709                            bundle: current_snapshot.bundle,
10710                            context_id: current_snapshot.context_id,
10711                            context: current_snapshot.context,
10712                            // This is a fresh record_id representing the session under its
10713                            // newly-observed identity; it has not yet produced any clean
10714                            // exit of its own, so it starts with no exported pack (the old
10715                            // record, still addressable under its own record_id, keeps
10716                            // whatever exported_context it already had).
10717                            exported_context: None,
10718                            task_binding: current_snapshot.task_binding,
10719                            created_at_unix_ms: now,
10720                            updated_at_unix_ms: now,
10721                            last_error: None,
10722                        };
10723                        records
10724                            .records
10725                            .insert(new_record_id.clone(), replacement.clone());
10726                        upserts.push(replacement);
10727                        rebind = Some(new_record_id);
10728                    } else if let Some(current) = records.records.get_mut(record_id) {
10729                        current.state = ManagedSessionState::Unavailable;
10730                        current.last_error =
10731                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10732                        upserts.push(current.clone());
10733                    }
10734                } else if let Some(current) = records.records.get_mut(record_id) {
10735                    current.provider_session = Some(identity);
10736                    current.active_session = Some(address.clone());
10737                    current.state = ManagedSessionState::Live;
10738                    current.updated_at_unix_ms = unix_time_ms();
10739                    current.last_error = None;
10740                    upserts.push(current.clone());
10741                }
10742            } else if let Some(current) = records.records.get_mut(record_id) {
10743                if resume_command_rejected {
10744                    current.active_session = None;
10745                    current.state = detached_record_state(current);
10746                    current.updated_at_unix_ms = unix_time_ms();
10747                    current.last_error =
10748                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10749                    upserts.push(current.clone());
10750                } else {
10751                    match &event.event {
10752                    ControlEventKind::Running { .. }
10753                    | ControlEventKind::ResumeAuthorized { .. }
10754                    | ControlEventKind::Resumed { .. } => {
10755                        current.active_session = Some(address.clone());
10756                        current.state = active_record_state(
10757                            identity_admitted,
10758                            current.provider_session.is_some(),
10759                        );
10760                        current.updated_at_unix_ms = unix_time_ms();
10761                        current.last_error = None;
10762                        upserts.push(current.clone());
10763                    }
10764                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10765                        current.active_session = None;
10766                        current.state = detached_record_state(current);
10767                        current.updated_at_unix_ms = unix_time_ms();
10768                        upserts.push(current.clone());
10769                    }
10770                    ControlEventKind::Failed { message }
10771                    | ControlEventKind::ResumeFailed { message } => {
10772                        current.active_session = None;
10773                        current.state = detached_record_state(current);
10774                        current.updated_at_unix_ms = unix_time_ms();
10775                        current.last_error = Some(
10776                            session_registry::sanitized_record_error_summary(message),
10777                        );
10778                        upserts.push(current.clone());
10779                    }
10780                    ControlEventKind::ResumeDenied { reason } => {
10781                        current.active_session = None;
10782                        current.state = detached_record_state(current);
10783                        current.updated_at_unix_ms = unix_time_ms();
10784                        current.last_error = Some(
10785                            session_registry::sanitized_record_error_summary(reason),
10786                        );
10787                        upserts.push(current.clone());
10788                    }
10789                        _ => {}
10790                    }
10791                }
10792            }
10793        }
10794        let materialization_owner_record = identity_was_observed.then(|| {
10795            rebind.clone().unwrap_or_else(|| record_id.clone())
10796        }).and_then(|candidate| {
10797            self.session_records
10798                .lock()
10799                .unwrap_or_else(|poisoned| poisoned.into_inner())
10800                .records
10801                .get(&candidate)
10802                .filter(|record| {
10803                    record.provider_session.is_some()
10804                        && record.active_session.as_ref() == Some(address)
10805                })
10806                .map(|_| candidate)
10807        });
10808        if let Some(owner_record_id) = materialization_owner_record {
10809            let materialization_id = self
10810                .session_bindings
10811                .lock()
10812                .unwrap_or_else(|poisoned| poisoned.into_inner())
10813                .get(&address.session.instance_id)
10814                .and_then(|binding| binding.materialization_id.clone());
10815            if let Some(materialization_id) = materialization_id {
10816                let mut transfer_failed = false;
10817                {
10818                    let mut materializations = self
10819                        .materializations
10820                        .lock()
10821                        .unwrap_or_else(|poisoned| poisoned.into_inner());
10822                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
10823                        match ownership.owner() {
10824                            MaterializationOwner::Session {
10825                                incarnation_id,
10826                                instance_id,
10827                                generation,
10828                            } if *incarnation_id == self.incarnation_id
10829                                && *instance_id == address.session.instance_id
10830                                && *generation == address.session.generation =>
10831                            {
10832                                transfer_failed = ownership
10833                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10834                                    .is_err();
10835                            }
10836                            MaterializationOwner::Record { record_id }
10837                                if record_id == &owner_record_id => {}
10838                            MaterializationOwner::Record { record_id } => {
10839                                let expected = record_id.clone();
10840                                transfer_failed = ownership
10841                                    .transfer_record_owner(
10842                                        &expected,
10843                                        owner_record_id.clone(),
10844                                        unix_time_ms(),
10845                                    )
10846                                    .is_err();
10847                            }
10848                            _ => transfer_failed = true,
10849                        }
10850                        if transfer_failed {
10851                            let _ = ownership.mark_recovery_required(unix_time_ms());
10852                        }
10853                    } else {
10854                        transfer_failed = true;
10855                    }
10856                }
10857                if transfer_failed {
10858                    if let Some(record) = self
10859                        .session_records
10860                        .lock()
10861                        .unwrap_or_else(|poisoned| poisoned.into_inner())
10862                        .records
10863                        .get_mut(&owner_record_id)
10864                    {
10865                        record.state = ManagedSessionState::Unavailable;
10866                        record.last_error = Some("environment-profile-unavailable".to_owned());
10867                        record.updated_at_unix_ms = unix_time_ms();
10868                        upserts.push(record.clone());
10869                    }
10870                }
10871            }
10872        }
10873        if let Some(new_record_id) = rebind.clone() {
10874            if let Some(binding) = self
10875                .session_bindings
10876                .lock()
10877                .unwrap_or_else(|poisoned| poisoned.into_inner())
10878                .get_mut(&address.session.instance_id)
10879            {
10880                binding.record_id = Some(new_record_id);
10881            }
10882        }
10883        if upserts.is_empty() && removals.is_empty() {
10884            return;
10885        }
10886        if self.persist_state_locked().is_err() {
10887            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10888            let mut records = self
10889                .session_records
10890                .lock()
10891                .unwrap_or_else(|poisoned| poisoned.into_inner());
10892            records.records = original_records;
10893            *self
10894                .materializations
10895                .lock()
10896                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10897            if let Some(current) = records.records.get_mut(record_id) {
10898                current.state = ManagedSessionState::Unavailable;
10899                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10900                current.updated_at_unix_ms = unix_time_ms();
10901                upserts = vec![current.clone()];
10902            } else {
10903                upserts.clear();
10904            }
10905            removals.clear();
10906            drop(records);
10907            if let Some(binding) = self
10908                .session_bindings
10909                .lock()
10910                .unwrap_or_else(|poisoned| poisoned.into_inner())
10911                .get_mut(&address.session.instance_id)
10912            {
10913                binding.record_id = Some(record_id.clone());
10914            }
10915            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10916        }
10917        for record in upserts {
10918            self.publish_record(record);
10919        }
10920        for record_id in removals {
10921            self.publish(NodeEvent::SessionRecordRemoved { record_id });
10922        }
10923    }
10924
10925    fn snapshot(&self) -> NodeSnapshot {
10926        let control = self.handle.snapshot();
10927        let bindings = self
10928            .session_bindings
10929            .lock()
10930            .unwrap_or_else(|poisoned| poisoned.into_inner())
10931            .clone();
10932        let workspace_roots = self
10933            .workspaces
10934            .read()
10935            .unwrap_or_else(|poisoned| poisoned.into_inner())
10936            .clone();
10937        let environment_profiles = self
10938            .environment_profiles
10939            .read()
10940            .unwrap_or_else(|poisoned| poisoned.into_inner());
10941        let mut workspaces = workspace_roots
10942            .iter()
10943            .map(|(workspace_id, canonical_root)| {
10944                (
10945                    workspace_id.clone(),
10946                    WorkspaceSnapshot {
10947                        workspace_id: workspace_id.clone(),
10948                        canonical_root: opaque_windows_path(canonical_root.clone()),
10949                        sessions: Vec::new(),
10950                        worktree_service_mode: Some(
10951                            self.worktree_service_modes
10952                                .get(workspace_id)
10953                                .copied()
10954                                .unwrap_or(WorktreeServiceMode::Manual),
10955                        ),
10956                        managed_worktree_profiles: Some(WorktreeProfileInventory {
10957                            profiles: self
10958                                .managed_worktree_profiles
10959                                .get(workspace_id)
10960                                .into_iter()
10961                                .flat_map(BTreeMap::values)
10962                                .map(|profile| ManagedWorktreeProfileSummary {
10963                                    id: profile.profile_id().clone(),
10964                                    revision: profile.revision().clone(),
10965                                    retention: profile.retention(),
10966                                })
10967                                .collect(),
10968                        }),
10969                    },
10970                )
10971            })
10972            .collect::<BTreeMap<_, _>>();
10973        let mut agent_progress = Vec::new();
10974        for session in &control.sessions {
10975            let Some(binding) = bindings.get(&session.instance_id) else {
10976                continue;
10977            };
10978            if binding.generation != session.generation {
10979                continue;
10980            }
10981            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10982                let address = SessionAddress {
10983                    workspace_id: binding.workspace_id.clone(),
10984                    session: SessionKey {
10985                        instance_id: session.instance_id,
10986                        generation: session.generation,
10987                    },
10988                };
10989                if let Some(progress) =
10990                    agent_progress_from_provider_snapshot(address, &session.provider)
10991                {
10992                    agent_progress.push(progress);
10993                }
10994                workspace.sessions.push(session.clone());
10995            }
10996        }
10997        NodeSnapshot {
10998            node_id: self.node_id.clone(),
10999            enabled_providers: self.enabled_providers.clone(),
11000            provider_runtime_statuses: {
11001                let updates = self
11002                    .provider_runtime_status_updates
11003                    .read()
11004                    .unwrap_or_else(|poisoned| poisoned.into_inner());
11005                ProviderRuntimeStatuses::new(
11006                    self.provider_runtime_statuses
11007                        .iter()
11008                        .filter(|status| !updates.contains_key(status.provider()))
11009                        .cloned()
11010                        .chain(updates.values().cloned()),
11011                )
11012                .expect("refreshed provider runtime inventory remains bounded and unique")
11013            },
11014            workspaces: workspaces.into_values().collect(),
11015            session_records: self
11016                .session_records
11017                .lock()
11018                .unwrap_or_else(|poisoned| poisoned.into_inner())
11019                .records
11020                .values()
11021                .cloned()
11022                .collect(),
11023            managed_worktrees: self.managed_worktrees
11024                .lock()
11025                .unwrap_or_else(|poisoned| poisoned.into_inner())
11026                .snapshots(),
11027            launch_inventory: Some(LaunchInventory {
11028                spawn_profiles: Some(
11029                    self.spawn_profiles
11030                        .iter()
11031                        .filter_map(|profile| {
11032                            resolved_spawn_profile_summary(profile, &environment_profiles)
11033                        })
11034                        .collect(),
11035                ),
11036                bundles: Some(
11037                    self.bundle_catalog
11038                        .read()
11039                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11040                        .iter()
11041                        .map(NodeBundle::receipt)
11042                        .collect(),
11043                ),
11044                // Opaque station allowlist ids from empty-default catalog /
11045                // optional startup loader. Never secrets. Dig2 probe stays stub.
11046                network_allowlists: {
11047                    let catalog = self
11048                        .network_allowlist_catalog
11049                        .read()
11050                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11051                    if catalog.is_empty() {
11052                        None
11053                    } else {
11054                        Some(catalog.ids().cloned().collect())
11055                    }
11056                },
11057            }),
11058            agent_progress,
11059        }
11060    }
11061
11062    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11063        assert!(
11064            !matches!(
11065                &event,
11066                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11067            ),
11068            "terminal frame and agent stream events must use their own dedicated live channel",
11069        );
11070        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11071        let sequence = history
11072            .last_sequence
11073            .checked_add(1)
11074            .expect("node event sequence exhausted");
11075        if history.events.len() == NODE_EVENT_HISTORY_MAX {
11076            if let Some(evicted) = history.events.pop_front() {
11077                history.replay_floor_sequence = evicted
11078                    .sequence
11079                    .checked_add(1)
11080                    .expect("node replay floor sequence exhausted");
11081                history.removed_record_providers.remove(&evicted.sequence);
11082            }
11083        }
11084        match &event {
11085            NodeEvent::HarnessMcpReadCall { .. } => {
11086                panic!("transient harness MCP calls must not enter durable event history")
11087            }
11088            NodeEvent::SessionRecordUpserted { record } => {
11089                history
11090                    .record_providers
11091                    .insert(record.record_id.clone(), record.provider.clone());
11092            }
11093            NodeEvent::SessionRecordRemoved { record_id } => {
11094                if let Some(provider) = history.record_providers.remove(record_id) {
11095                    history.removed_record_providers.insert(sequence, provider);
11096                }
11097            }
11098            NodeEvent::ManagedWorktreeUpserted { .. }
11099            | NodeEvent::ManagedWorktreeRemoved { .. }
11100            | NodeEvent::Control { .. }
11101            | NodeEvent::ControllerChanged { .. }
11102            | NodeEvent::SessionRecordHistorySummarized { .. }
11103            | NodeEvent::WorkspaceAdded { .. }
11104            | NodeEvent::WorkspaceRemoved { .. }
11105            | NodeEvent::ResyncRequired { .. }
11106            | NodeEvent::AgentStream { .. }
11107            | NodeEvent::TerminalFrame { .. } => {}
11108        }
11109        let envelope = NodeEventEnvelope { sequence, event };
11110        history.events.push_back(envelope.clone());
11111        history.last_sequence = sequence;
11112        let _ = self.event_tx.send(envelope.clone());
11113        drop(history);
11114        envelope
11115    }
11116
11117    /// Publishes a transient (never durable) `NodeEvent` -- today only
11118    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
11119    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
11120    /// why this must never ride the shared `event_tx` bus). Returns whether
11121    /// the send reached at least one subscriber: `broadcast::Sender::send`
11122    /// fails in exactly one way, no active receiver at that instant, and
11123    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
11124    /// return to fail the pending call immediately rather than leave it to
11125    /// expire silently at `LOCAL_READ_TIMEOUT`/
11126    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
11127    fn publish_transient(&self, event: NodeEvent) -> bool {
11128        assert!(event.requires_harness_mcp_proxy_capability());
11129        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11130        let envelope = NodeEventEnvelope { sequence: 0, event };
11131        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11132            Ok(_) => true,
11133            Err(broadcast::error::SendError(undelivered)) => {
11134                if let Some(NodeEventEnvelope {
11135                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11136                    ..
11137                }) = undelivered.first() {
11138                    // `request.content_type` only, never a per-tool label --
11139                    // this crate does not decode `request.body` and does not
11140                    // hold `hatchery-harness-api` (Nested Control Plane
11141                    // doctrine, Law 3; see this crate's dependency on
11142                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
11143                    tracing::warn!(
11144                        call_id = call_id.as_str(),
11145                        content_type = ?request.content_type,
11146                        cause = "no-subscribers",
11147                        "harness MCP proxy event could not be published",
11148                    );
11149                }
11150                false
11151            }
11152        }
11153    }
11154
11155    fn publish_terminal_frames(&self) {
11156        let snapshot = self.handle.snapshot();
11157        let bindings = self
11158            .session_bindings
11159            .lock()
11160            .unwrap_or_else(|poisoned| poisoned.into_inner());
11161        let candidates = snapshot
11162            .sessions
11163            .iter()
11164            .filter_map(|session| {
11165                let binding = bindings.get(&session.instance_id)?;
11166                if binding.generation != session.generation {
11167                    return None;
11168                }
11169                let frame = session.terminal_frame.clone()?;
11170                crate::session_signals::feed(
11171                    binding.workspace_id.as_str(),
11172                    session.instance_id.0,
11173                    session.agent_id.as_str(),
11174                    &frame.contents,
11175                );
11176                Some((
11177                    SessionAddress {
11178                        workspace_id: binding.workspace_id.clone(),
11179                        session: SessionKey {
11180                            instance_id: session.instance_id,
11181                            generation: session.generation,
11182                        },
11183                    },
11184                    frame,
11185                ))
11186            })
11187            .collect();
11188        drop(bindings);
11189        self.publish_terminal_frame_candidates(candidates);
11190    }
11191
11192    fn publish_terminal_frame_candidates(
11193        &self,
11194        candidates: Vec<(SessionAddress, TerminalFrame)>,
11195    ) {
11196        let mut watermarks = self
11197            .terminal_frame_watermarks
11198            .lock()
11199            .unwrap_or_else(|poisoned| poisoned.into_inner());
11200        let mut events = Vec::with_capacity(candidates.len());
11201        for (address, frame) in candidates {
11202            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11203            let advanced = watermarks
11204                .get(&address.session.instance_id)
11205                .map_or(true, |(current_address, sequence)| {
11206                    current_address != &address || frame.sequence > *sequence
11207                });
11208            if !advanced {
11209                continue;
11210            }
11211            if is_first_frame {
11212                // Do NOT sessionize on first PTY output: a session is a
11213                // provider conversation id, not bytes on the wire. Drafts
11214                // leave the provisional map only when HQ attaches
11215                // (Resize / TerminalBytes). The attach-never-arrived timer
11216                // (PROVISIONAL_SPAWN_GRACE_MS) covers deploy lag; HQ's
11217                // disconnect grace starts only on a real client WS close.
11218                // This is the first PTY output this node has observed for
11219                // this session -- a one-time event per spawn, not per-frame
11220                // chatter (the watermark makes sure of that). A session that
11221                // reaches this line with no controller attached anywhere on
11222                // the node is running unobserved: exactly how processes from
11223                // repeated spawn attempts went orphaned for hours without a
11224                // single line about it.
11225                if self.controller_state().is_none() {
11226                    tracing::warn!(
11227                        node_id = %self.node_id,
11228                        workspace_id = %address.workspace_id,
11229                        session = ?address.session,
11230                        terminal_size = ?frame.size,
11231                        "session producing output with no controller attached",
11232                    );
11233                } else {
11234                    tracing::info!(
11235                        node_id = %self.node_id,
11236                        workspace_id = %address.workspace_id,
11237                        session = ?address.session,
11238                        terminal_size = ?frame.size,
11239                        "session produced its first output",
11240                    );
11241                }
11242            }
11243            watermarks.insert(
11244                address.session.instance_id,
11245                (address.clone(), frame.sequence),
11246            );
11247            if let Some((at, reported)) = self
11248                .pty_input_marks
11249                .lock()
11250                .unwrap_or_else(|poisoned| poisoned.into_inner())
11251                .get_mut(&address.session.instance_id)
11252            {
11253                // First frame after an input: PTY write -> output published.
11254                if !*reported && at.elapsed() < Duration::from_secs(5) {
11255                    *reported = true;
11256                    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");
11257                }
11258            }
11259            events.push(NodeEvent::TerminalFrame { address, frame });
11260        }
11261        drop(watermarks);
11262        if events.is_empty() {
11263            return;
11264        }
11265        let mut history = self
11266            .history
11267            .lock()
11268            .unwrap_or_else(|poisoned| poisoned.into_inner());
11269        let mut envelopes = Vec::with_capacity(events.len());
11270        for event in events {
11271            let sequence = history
11272                .last_sequence
11273                .checked_add(1)
11274                .expect("node event sequence exhausted");
11275            history.last_sequence = sequence;
11276            envelopes.push(NodeEventEnvelope { sequence, event });
11277        }
11278        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11279        drop(history);
11280    }
11281
11282    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11283    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11284    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11285    /// counter every other event kind shares, never a counter of its own --
11286    /// `queue_connection_event`/`queue_connection_event_batch` compare
11287    /// `envelope.sequence` against one `discard_events_through` watermark
11288    /// regardless of which broadcast channel it arrived on, so a
11289    /// per-channel counter would desynchronize that comparison.
11290    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11291        let mut history = self
11292            .history
11293            .lock()
11294            .unwrap_or_else(|poisoned| poisoned.into_inner());
11295        let sequence = history
11296            .last_sequence
11297            .checked_add(1)
11298            .expect("node event sequence exhausted");
11299        history.last_sequence = sequence;
11300        drop(history);
11301        // Debug, not info/warn -- this fires on every single chunk, but it
11302        // is exactly the counter the next drop-measurement pass needs: what
11303        // the node actually emitted (`source_sequence`, `kind`) versus what
11304        // a harness subscriber's own forwarder later logs having received
11305        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11306        // event"), so a gap between the two logs names itself instead of
11307        // needing a new counter added ad hoc.
11308        tracing::debug!(
11309            source_sequence = chunk.source_sequence,
11310            kind = agent_stream_chunk_kind_label(&chunk.kind),
11311            envelope_sequence = sequence,
11312            "publishing agent stream chunk",
11313        );
11314        let envelope = NodeEventEnvelope {
11315            sequence,
11316            event: NodeEvent::AgentStream { address, chunk },
11317        };
11318        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11319    }
11320
11321    fn current_sequence(&self) -> u64 {
11322        self.history
11323            .lock()
11324            .unwrap_or_else(|poisoned| poisoned.into_inner())
11325            .last_sequence
11326    }
11327
11328    fn controller_state(&self) -> Option<ControllerState> {
11329        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11330        let lease = controller.as_ref().copied()?;
11331        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11332        if remaining.is_zero() {
11333            *controller = None;
11334            return None;
11335        }
11336        Some(ControllerState {
11337            connection_id: lease.connection_id,
11338            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11339        })
11340    }
11341
11342    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11343        if role != ClientRole::Operator {
11344            tracing::warn!(
11345                node_id = %self.node_id,
11346                connection_id,
11347                "controller lease rejected: observer connections cannot acquire control",
11348            );
11349            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11350        }
11351        let lease_ms = if lease_ms == 0 {
11352            DEFAULT_CONTROLLER_LEASE_MS
11353        } else {
11354            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11355        };
11356        let now = Instant::now();
11357        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11358        if let Some(active) = controller.as_ref() {
11359            if active.connection_id != connection_id && active.expires_at > now {
11360                tracing::warn!(
11361                    node_id = %self.node_id,
11362                    connection_id,
11363                    holder_connection_id = active.connection_id,
11364                    "controller lease rejected: another operator holds it",
11365                );
11366                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11367            }
11368        }
11369        // A reconnect (this connection already holds the lease, renewing
11370        // before expiry) happens on a periodic cadence; logging it at INFO
11371        // every renewal would be exactly the heartbeat chatter this crate
11372        // family's level discipline forbids. A genuine attach -- the
11373        // controller identity actually changing -- is a state change and
11374        // gets INFO; a same-connection renewal gets DEBUG.
11375        let is_new_attach = match controller.as_ref() {
11376            Some(active) => active.connection_id != connection_id,
11377            None => true,
11378        };
11379        *controller = Some(ControllerLease {
11380            connection_id,
11381            expires_at: now + Duration::from_millis(lease_ms),
11382        });
11383        drop(controller);
11384        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11385        if is_new_attach {
11386            tracing::info!(
11387                node_id = %self.node_id,
11388                connection_id,
11389                lease_ms,
11390                "controller lease acquired",
11391            );
11392        } else {
11393            tracing::debug!(
11394                node_id = %self.node_id,
11395                connection_id,
11396                lease_ms,
11397                "controller lease renewed",
11398            );
11399        }
11400        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11401        Ok(state)
11402    }
11403
11404    fn release_controller(&self, connection_id: u64) -> bool {
11405        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11406        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11407        if released {
11408            *controller = None;
11409        }
11410        drop(controller);
11411        if released {
11412            tracing::info!(
11413                node_id = %self.node_id,
11414                connection_id,
11415                "controller lease released",
11416            );
11417            self.publish(NodeEvent::ControllerChanged { controller: None });
11418        }
11419        released
11420    }
11421
11422    /// Mark a fresh Spawn as provisional and arm the attach-never-arrived
11423    /// reaper (PROVISIONAL_SPAWN_GRACE_MS from spawn). Removed from this map
11424    /// only when HQ attaches (Resize / TerminalBytes) — that is *not* a
11425    /// provider session; it only cancels the orphan timer. Disconnect/reload
11426    /// draft kills are owned by HQ and arm only on a real client WS close.
11427    /// ACP path of launch options: once the agent advertises its config options, write the
11428    /// pending model / effort through session/set_config_option (session/set_model when the
11429    /// agent has no model option). Gaps are logged, never forced.
11430    fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11431        let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11432            return;
11433        };
11434        let id = address.session.instance_id;
11435        let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11436            return;
11437        };
11438        let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11439            .iter()
11440            .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11441            .collect();
11442        let (writes, set_model, effort_gap) =
11443            backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11444        if set_model {
11445            if let Some(model_id) = model.clone() {
11446                if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11447                    tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11448                }
11449            }
11450        }
11451        for (option_id, value_json) in writes {
11452            if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11453                tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11454            }
11455        }
11456        if effort_gap {
11457            tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11458        }
11459        tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11460    }
11461
11462    fn mark_spawn_provisional(&self, address: SessionAddress) {
11463        let id = address.session.instance_id;
11464        self.provisional_spawns
11465            .lock()
11466            .unwrap_or_else(|poisoned| poisoned.into_inner())
11467            .insert(id, (address, Instant::now()));
11468        tracing::info!(
11469            node_id = %self.node_id,
11470            instance_id = id.0,
11471            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11472            "draft spawn; waiting for HQ view",
11473        );
11474    }
11475
11476    fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11477        let removed = self
11478            .provisional_spawns
11479            .lock()
11480            .unwrap_or_else(|poisoned| poisoned.into_inner())
11481            .remove(&instance_id);
11482        if removed.is_some() {
11483            tracing::info!(
11484                node_id = %self.node_id,
11485                instance_id = instance_id.0,
11486                "draft spawn claimed by HQ view",
11487            );
11488        }
11489    }
11490
11491    fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11492        let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11493        let mut map = self
11494            .provisional_spawns
11495            .lock()
11496            .unwrap_or_else(|poisoned| poisoned.into_inner());
11497        let due: Vec<AgentInstanceId> = map
11498            .iter()
11499            .filter(|(_, (_, at))| at.elapsed() >= grace)
11500            .map(|(id, _)| *id)
11501            .collect();
11502        due.into_iter()
11503            .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11504            .collect()
11505    }
11506
11507    async fn reap_provisional_spawn(&self, address: SessionAddress) {
11508        let instance_id = address.session.instance_id;
11509        tracing::warn!(
11510            node_id = %self.node_id,
11511            instance_id = instance_id.0,
11512            workspace = %address.workspace_id,
11513            reason = "attach_never_arrived",
11514            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11515            "draft spawn never viewed; stopping PTY",
11516        );
11517        let _ = self.dispatch(ControlCommand::Stop {
11518            instance_id,
11519            force: true,
11520        });
11521        let _ = self.remove_session(&address).await;
11522    }
11523
11524    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11525        if role != ClientRole::Operator {
11526            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11527        }
11528        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11529        let now = Instant::now();
11530        match controller.as_ref().copied() {
11531            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11532            Some(active) if active.expires_at <= now => {
11533                *controller = None;
11534                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11535            }
11536            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11537        }
11538    }
11539
11540    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11541        if !self
11542            .workspaces
11543            .read()
11544            .unwrap_or_else(|poisoned| poisoned.into_inner())
11545            .contains_key(&address.workspace_id)
11546        {
11547            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11548        }
11549        let binding = self
11550            .session_bindings
11551            .lock()
11552            .unwrap_or_else(|poisoned| poisoned.into_inner())
11553            .get(&address.session.instance_id)
11554            .cloned()
11555            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11556        if binding.workspace_id != address.workspace_id {
11557            return Err(failure(
11558                NodeFailureCode::SessionWorkspaceMismatch,
11559                "session belongs to another workspace",
11560            ));
11561        }
11562        if binding.generation != address.session.generation {
11563            return Err(failure(
11564                NodeFailureCode::StaleGeneration,
11565                &format!(
11566                    "stale session generation: expected {}, received {}",
11567                    binding.generation.0,
11568                    address.session.generation.0,
11569                ),
11570            ));
11571        }
11572        let snapshot = self.handle.snapshot();
11573        let Some(current) = snapshot
11574            .sessions
11575            .iter()
11576            .find(|item| item.instance_id == address.session.instance_id)
11577        else {
11578            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11579        };
11580        if current.generation != address.session.generation {
11581            return Err(failure(
11582                NodeFailureCode::StaleGeneration,
11583                &format!(
11584                    "stale session generation: expected {}, received {}",
11585                    current.generation.0,
11586                    address.session.generation.0,
11587                ),
11588            ));
11589        }
11590        Ok(current.agent_id.clone())
11591    }
11592
11593    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11594        self.dispatch_envelope(self.prepare_command(command))
11595    }
11596
11597    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11598        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11599        CommandEnvelope {
11600            id: CommandId(id),
11601            command,
11602        }
11603    }
11604
11605    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11606        let command_id = envelope.id;
11607        self.handle
11608            .dispatch(envelope)
11609            .map_err(|error| match error {
11610                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11611                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11612            })?;
11613        Ok(command_id)
11614    }
11615
11616    /// Records the kernel's own reason for asynchronously refusing
11617    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
11618    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
11619    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
11620    /// first, and if the map is still at capacity after that, the single
11621    /// oldest surviving entry is evicted -- `CommandId` is monotonically
11622    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
11623    /// is always the oldest.
11624    fn record_rejected_command(
11625        &self,
11626        command_id: CommandId,
11627        message: String,
11628        unsupported_transport: bool,
11629    ) {
11630        let now = Instant::now();
11631        let mut reasons = self.rejected_command_reasons.lock()
11632            .unwrap_or_else(|poisoned| poisoned.into_inner());
11633        reasons.retain(|_, reason| reason.expires_at > now);
11634        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11635            if let Some(&oldest) = reasons.keys().next() {
11636                reasons.remove(&oldest);
11637            }
11638        }
11639        reasons.insert(
11640            command_id,
11641            RejectedCommandReason {
11642                message,
11643                unsupported_transport,
11644                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11645            },
11646        );
11647    }
11648
11649    /// Removes and returns the kernel's reason for refusing `command_id`,
11650    /// if the drive loop has recorded one and it has not expired -- see
11651    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
11652    /// wait loop calls this instead of blindly polling out its whole
11653    /// deadline when the command it is waiting on turns out to have been
11654    /// refused rather than merely slow.
11655    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11656        let now = Instant::now();
11657        let mut reasons = self.rejected_command_reasons.lock()
11658            .unwrap_or_else(|poisoned| poisoned.into_inner());
11659        match reasons.remove(&command_id) {
11660            Some(reason) if reason.expires_at > now => Some(reason),
11661            _ => None,
11662        }
11663    }
11664
11665    async fn dispatch_bounded(
11666        &self,
11667        command: ControlCommand,
11668        timeout_duration: Duration,
11669    ) -> Result<CommandId, NodeFailure> {
11670        let deadline = Instant::now() + timeout_duration;
11671        loop {
11672            match self.dispatch(command.clone()) {
11673                Ok(command_id) => return Ok(command_id),
11674                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11675                    sleep(Duration::from_millis(2)).await;
11676                }
11677                Err(error) => return Err(error),
11678            }
11679        }
11680    }
11681
11682    fn internal_session_snapshot(
11683        &self,
11684        address: &SessionAddress,
11685    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11686        self.validate_address(address)?;
11687        self.handle
11688            .snapshot()
11689            .sessions
11690            .clone()
11691            .into_iter()
11692            .find(|session| {
11693                session.instance_id == address.session.instance_id
11694                    && session.generation == address.session.generation
11695            })
11696            .ok_or_else(|| {
11697                failure(
11698                    NodeFailureCode::UnknownSession,
11699                    "session snapshot is unavailable",
11700                )
11701            })
11702    }
11703
11704    /// Confirms `address` names a live session spawned over the ACP
11705    /// transport and returns its current snapshot -- the gate every
11706    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
11707    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
11708    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
11709    /// the session's own recorded `TransportKind` does, and that lives on
11710    /// the live snapshot, not the binding.
11711    fn require_acp_session(
11712        &self,
11713        address: &SessionAddress,
11714    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11715        let snapshot = self.internal_session_snapshot(address)?;
11716        if snapshot.transport != TransportKind::Acp {
11717            return Err(failure(
11718                NodeFailureCode::UnsupportedTransport,
11719                "this verb requires a session spawned over the ACP transport",
11720            ));
11721        }
11722        Ok(snapshot)
11723    }
11724
11725    async fn dispatch_history_bounded(
11726        &self,
11727        address: &SessionAddress,
11728        command: ControlCommand,
11729        operation: HistoryOperation,
11730    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11731        self.validate_address(address)?;
11732        let started_after = self.current_sequence();
11733        let command_id = self
11734            .dispatch_bounded(
11735                command,
11736                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11737            )
11738            .await?;
11739        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11740        let mut scan_after = started_after;
11741        let mut accepted_after = None;
11742        loop {
11743            let events = self
11744                .history
11745                .lock()
11746                .unwrap_or_else(|poisoned| poisoned.into_inner())
11747                .events
11748                .iter()
11749                .filter(|envelope| envelope.sequence > scan_after)
11750                .cloned()
11751                .collect::<Vec<_>>();
11752            for envelope in events {
11753                scan_after = scan_after.max(envelope.sequence);
11754                let NodeEvent::Control {
11755                    address: event_address,
11756                    event,
11757                } = envelope.event
11758                else {
11759                    continue;
11760                };
11761                if event_address != *address {
11762                    continue;
11763                }
11764                if event.command_id == Some(command_id) {
11765                    match &event.event {
11766                        ControlEventKind::CommandRejected { .. } => {
11767                            return Err(failure(
11768                                NodeFailureCode::InvalidRequest,
11769                                "history request was rejected",
11770                            ));
11771                        }
11772                        ControlEventKind::HistoryRequested {
11773                            operation: requested,
11774                            ..
11775                        } if requested == &operation => {
11776                            accepted_after = Some(envelope.sequence);
11777                            continue;
11778                        }
11779                        _ => {}
11780                    }
11781                }
11782                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11783                    match (&operation, &event.event) {
11784                        (
11785                            HistoryOperation::Discover { .. },
11786                            ControlEventKind::HistoryDiscovered { .. },
11787                        )
11788                        | (
11789                            HistoryOperation::Load { .. },
11790                            ControlEventKind::HistoryLoaded { .. },
11791                        ) => return self.internal_session_snapshot(address),
11792                        (_, ControlEventKind::HistoryFailed { .. }) => {
11793                            return Err(failure(
11794                                NodeFailureCode::BackendOperationFailed,
11795                                "history operation failed",
11796                            ));
11797                        }
11798                        _ => {}
11799                    }
11800                }
11801            }
11802            if Instant::now() >= deadline {
11803                return Err(failure(
11804                    NodeFailureCode::BackendBusy,
11805                    if accepted_after.is_some() {
11806                        "history operation did not settle before the bounded deadline"
11807                    } else {
11808                        "history operation was not accepted before the bounded deadline"
11809                    },
11810                ));
11811            }
11812            sleep(Duration::from_millis(2)).await;
11813        }
11814    }
11815
11816    async fn discover_history(
11817        &self,
11818        address: &SessionAddress,
11819        limit: u16,
11820    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11821        let query = HistoryQuery {
11822            working_directory: Some(self.workspace_root(&address.workspace_id)?),
11823            limit,
11824        };
11825        query
11826            .validate()
11827            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11828        let snapshot = self
11829            .dispatch_history_bounded(
11830                address,
11831                ControlCommand::DiscoverHistory {
11832                    instance_id: address.session.instance_id,
11833                    query: query.clone(),
11834                },
11835                HistoryOperation::Discover { query },
11836            )
11837            .await?;
11838        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11839            return Err(failure(
11840                NodeFailureCode::BackendOperationFailed,
11841                "history discovery settled into an invalid state",
11842            ));
11843        }
11844        Ok(snapshot.history.candidates)
11845    }
11846
11847    async fn load_history(
11848        &self,
11849        address: &SessionAddress,
11850        candidate_id: String,
11851    ) -> Result<HistorySessionRecord, NodeFailure> {
11852        validate_candidate_id(&candidate_id)
11853            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11854        let snapshot = self
11855            .dispatch_history_bounded(
11856                address,
11857                ControlCommand::LoadHistory {
11858                    instance_id: address.session.instance_id,
11859                    candidate_id: candidate_id.clone(),
11860                },
11861                HistoryOperation::Load {
11862                    candidate_id: candidate_id.clone(),
11863                },
11864            )
11865            .await?;
11866        if snapshot.history.pending.is_some()
11867            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11868        {
11869            return Err(failure(
11870                NodeFailureCode::BackendOperationFailed,
11871                "history load settled into an invalid state",
11872            ));
11873        }
11874        snapshot.history.loaded.ok_or_else(|| {
11875            failure(
11876                NodeFailureCode::BackendOperationFailed,
11877                "history load produced no bounded session",
11878            )
11879        })
11880    }
11881
11882    /// `allow_clean_detachment`: `false` for the on-demand
11883    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
11884    /// managed record must stay untouched, still live, for the whole call);
11885    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
11886    /// reacts to the very clean-exit event that detaches the record out from
11887    /// under this same call — see `session_record_export_target_matches`.
11888    async fn export_context_pack_for_session_record(
11889        &self,
11890        record_id: &SessionRecordId,
11891        session: &SessionAddress,
11892        allow_clean_detachment: bool,
11893    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11894        // C2 replaces a relayed failure's prose with a canned per-code
11895        // string (`C2NodeFailure`'s own `From`), so this path's dozen
11896        // distinct refusals are indistinguishable to anyone downstream --
11897        // and a refusal here expires a continuation, which keeps no field
11898        // for why. The prose stays on the node, where it was written.
11899        let outcome = self
11900            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11901            .await;
11902        if let Err(failure) = &outcome {
11903            tracing::warn!(
11904                record = %record_id,
11905                code = ?failure.code,
11906                reason = %failure.message,
11907                "session-record context-pack export refused",
11908            );
11909        }
11910        outcome
11911    }
11912
11913    async fn export_context_pack_for_session_record_inner(
11914        &self,
11915        record_id: &SessionRecordId,
11916        session: &SessionAddress,
11917        allow_clean_detachment: bool,
11918    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11919        // Several steps here refuse with the same BackendOperationFailed
11920        // code and no prose of their own, so which step failed is
11921        // otherwise unrecoverable -- and a refusal here expires a
11922        // continuation.
11923        let step = |name: &'static str| move |failure: NodeFailure| {
11924            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11925            failure
11926        };
11927        let record = self.record(record_id).map_err(step("record-lookup"))?;
11928        // `provider_session` is `Some` only for a provider whose resolved
11929        // runtime policy admits live identity verification (raw PTY plus a
11930        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
11931        // The PTY-sidecar-observation shape (no fleet provider today, but
11932        // capability-keyed rather than vendor-locked) hardcodes
11933        // `provider_session_identity: false` in `provider_runtime::evaluate`,
11934        // so its records go straight to `Live` with `provider_session: None`
11935        // and stay that way for the record's entire life -- that is this
11936        // shape's permanent resting state, not a race with observation, and
11937        // `discover_history`/`load_history` below do not need an identity to
11938        // do their job for it. `IdentityPending` is the actual race (an
11939        // identity-admitted provider whose observation has not landed yet):
11940        // it keeps refusing here rather than falling back, because that
11941        // provider's own history the fallback would otherwise trust is not
11942        // guaranteed to be exact without the identity it is still waiting on.
11943        let identity = match record.provider_session.clone() {
11944            Some(identity) => {
11945                if identity.key != ProviderSessionKey::SessionId {
11946                    return Err(step("provider-identity-key")(failure(
11947                        NodeFailureCode::BackendOperationFailed,
11948                        "managed session provider identity cannot bind history exactly",
11949                    )));
11950                }
11951                Some(identity)
11952            }
11953            None if record.state == ManagedSessionState::IdentityPending => {
11954                return Err(step("provider-session-identity")(failure(
11955                    NodeFailureCode::BackendOperationFailed,
11956                    "managed session has not yet observed its provider session identity",
11957                )));
11958            }
11959            None => None,
11960        };
11961        self.revalidate_session_record_context_export(
11962            &record,
11963            identity.as_ref(),
11964            session,
11965            allow_clean_detachment,
11966        ).map_err(step("revalidate-before-discovery"))?;
11967        let candidates = self
11968            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11969            .await
11970            .map_err(step("discover-history"))?;
11971        // With no verified identity to filter by, the discovered set must
11972        // still resolve to exactly one candidate: the same ambiguity refusal
11973        // an identity filter would otherwise produce is the only safety
11974        // property left available for this shape.
11975        let candidate_id = select_history_candidate(
11976            &candidates,
11977            identity.as_ref().map(|identity| identity.id.as_str()),
11978        ).map_err(step("unique-candidate"))?;
11979        let loaded = self.load_history(session, candidate_id.clone()).await
11980            .map_err(step("load-history"))?;
11981        if let Some(identity) = identity.as_ref() {
11982            if loaded.session_id != identity.id {
11983                return Err(failure(
11984                    NodeFailureCode::SessionRecordConflict,
11985                    "loaded history identity does not match the managed session record",
11986                ));
11987            }
11988        }
11989        self.revalidate_session_record_context_export(
11990            &record,
11991            identity.as_ref(),
11992            session,
11993            allow_clean_detachment,
11994        )?;
11995        self.revalidate_loaded_history(session, &candidate_id, &loaded)
11996            .map_err(step("revalidate-loaded-history"))?;
11997        let pack = self.materialize_context_pack(session).await
11998            .map_err(step("materialize-context-pack"))?;
11999        self.commit_context_pack_for_session_record(
12000            &record,
12001            identity.as_ref(),
12002            session,
12003            &candidate_id,
12004            &loaded,
12005            pack,
12006            allow_clean_detachment,
12007        )
12008    }
12009
12010    async fn export_context_pack(
12011        &self,
12012        address: &SessionAddress,
12013    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12014        let pack = self.materialize_context_pack(address).await?;
12015        self.commit_context_pack(pack)
12016    }
12017
12018    async fn materialize_context_pack(
12019        &self,
12020        address: &SessionAddress,
12021    ) -> Result<NodeContextPack, NodeFailure> {
12022        let snapshot = self.internal_session_snapshot(address)?;
12023        if !context_pack_source_status_is_usable(&snapshot.status) {
12024            return Err(failure(
12025                NodeFailureCode::InvalidRequest,
12026                "context export requires a running or successfully exited source session",
12027            ));
12028        }
12029        if snapshot.history.pending.is_some()
12030            || snapshot.history.loaded_candidate_id.is_none()
12031        {
12032            return Err(failure(
12033                NodeFailureCode::InvalidRequest,
12034                "context export requires settled loaded history",
12035            ));
12036        }
12037        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12038            failure(
12039                NodeFailureCode::InvalidRequest,
12040                "context export requires settled loaded history",
12041            )
12042        })?;
12043        let source_provider = snapshot.agent_id.clone();
12044        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12045        let repository = self
12046            .context_pack_repository(&address.workspace_id)
12047            .await?;
12048        let current = self.internal_session_snapshot(address)?;
12049        if !context_pack_source_status_is_usable(&current.status)
12050            || current.agent_id != source_provider
12051            || current.history.pending.is_some()
12052            || current.history.loaded_candidate_id != loaded_candidate_id
12053            || current.history.loaded.as_ref() != Some(&loaded)
12054        {
12055            return Err(failure(
12056                NodeFailureCode::ContextPackMaterializationFailed,
12057                "context source changed during bounded repository capture",
12058            ));
12059        }
12060        NodeContextPack::export_with_repository(
12061            ContextPackLineageReceipt {
12062                source_node_id: self.node_id.clone(),
12063                source_session: address.clone(),
12064                source_provider,
12065            },
12066            &loaded,
12067            Some(repository),
12068        )
12069        .map_err(|_| {
12070            failure(
12071                NodeFailureCode::ContextPackMaterializationFailed,
12072                "bounded context pack export failed",
12073            )
12074        })
12075    }
12076
12077    /// Write-through to the durable [`ContextPackStore`], if one is
12078    /// configured (`state_path` is `Some`). A node with no durable state
12079    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
12080    /// open at all and this is a no-op, matching `persist_state`'s own
12081    /// "durability optional, degrade gracefully when unconfigured" idiom.
12082    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12083        let mut store = self
12084            .context_pack_store
12085            .lock()
12086            .unwrap_or_else(|poisoned| poisoned.into_inner());
12087        let Some(store) = store.as_mut() else {
12088            return Ok(());
12089        };
12090        store.commit(pack).map_err(|_| {
12091            failure(
12092                NodeFailureCode::ContextPackMaterializationFailed,
12093                "durable context pack commit failed",
12094            )
12095        })
12096    }
12097
12098    fn commit_context_pack(
12099        &self,
12100        pack: NodeContextPack,
12101    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12102        self.commit_context_pack_durably(&pack)?;
12103        self.context_catalog
12104            .write()
12105            .unwrap_or_else(|poisoned| poisoned.into_inner())
12106            .insert(pack)
12107            .map_err(|_| {
12108                failure(
12109                    NodeFailureCode::ContextPackMaterializationFailed,
12110                    "context pack catalog is full",
12111                )
12112            })
12113    }
12114
12115    fn commit_context_pack_for_session_record(
12116        &self,
12117        expected: &ManagedSessionRecord,
12118        identity: Option<&ProviderSessionIdentity>,
12119        session: &SessionAddress,
12120        candidate_id: &str,
12121        loaded: &HistorySessionRecord,
12122        pack: NodeContextPack,
12123        allow_clean_detachment: bool,
12124    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12125        let current_root = self.workspace_root(&expected.workspace_id)?;
12126        let _transaction = self
12127            .state_transaction
12128            .lock()
12129            .unwrap_or_else(|poisoned| poisoned.into_inner());
12130        let records = self
12131            .session_records
12132            .lock()
12133            .unwrap_or_else(|poisoned| poisoned.into_inner());
12134        let current = records.records.get(&expected.record_id).ok_or_else(|| {
12135            failure(
12136                NodeFailureCode::SessionRecordConflict,
12137                "managed session record disappeared during context export",
12138            )
12139        })?;
12140        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12141            return Err(failure(
12142                NodeFailureCode::SessionRecordConflict,
12143                "managed session record changed during context export",
12144            ));
12145        }
12146        let bindings = self
12147            .session_bindings
12148            .lock()
12149            .unwrap_or_else(|poisoned| poisoned.into_inner());
12150        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12151            failure(
12152                NodeFailureCode::SessionRecordConflict,
12153                "managed session runtime binding disappeared during context export",
12154            )
12155        })?;
12156        let runtime = self
12157            .handle
12158            .snapshot()
12159            .sessions
12160            .iter()
12161            .find(|snapshot| {
12162                snapshot.instance_id == session.session.instance_id
12163                    && snapshot.generation == session.session.generation
12164            })
12165            .cloned()
12166            .ok_or_else(|| {
12167                failure(
12168                    NodeFailureCode::SessionRecordConflict,
12169                    "managed session runtime disappeared during context export",
12170                )
12171            })?;
12172        let receipt = pack.receipt();
12173        if !session_record_context_export_binding_is_exact(
12174            expected,
12175            current,
12176            identity,
12177            session,
12178            binding,
12179            &runtime.agent_id,
12180            &current_root,
12181            allow_clean_detachment,
12182        ) || !session_record_context_export_source_is_usable(
12183            current,
12184            &runtime.status,
12185            allow_clean_detachment,
12186        ) || runtime.history.pending.is_some()
12187            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12188            || runtime.history.loaded.as_ref() != Some(loaded)
12189            || receipt.lineage.source_node_id != self.node_id
12190            || receipt.lineage.source_session != *session
12191            || receipt.lineage.source_provider != current.provider
12192            || receipt.source_message_count != loaded.message_count
12193        {
12194            return Err(failure(
12195                NodeFailureCode::SessionRecordConflict,
12196                "managed session source changed before context catalog commit",
12197            ));
12198        }
12199        self.commit_context_pack_durably(&pack)?;
12200        self.context_catalog
12201            .write()
12202            .unwrap_or_else(|poisoned| poisoned.into_inner())
12203            .insert(pack)
12204            .map_err(|_| {
12205                failure(
12206                    NodeFailureCode::ContextPackMaterializationFailed,
12207                    "context pack catalog is full",
12208                )
12209            })
12210    }
12211
12212    async fn context_pack_repository(
12213        &self,
12214        workspace_id: &WorkspaceId,
12215    ) -> Result<ContextPackRepository, NodeFailure> {
12216        let first = self
12217            .context_pack_repository_observation(workspace_id)
12218            .await?;
12219        let second = self
12220            .context_pack_repository_observation(workspace_id)
12221            .await?;
12222        stable_context_pack_repository(first, second)
12223    }
12224
12225    async fn context_pack_repository_observation(
12226        &self,
12227        workspace_id: &WorkspaceId,
12228    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12229        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12230        let head = if git.is_repository {
12231            let root = self.workspace_root(workspace_id)?;
12232            match resolve_base_commit_with_timeout(
12233                &root,
12234                "HEAD",
12235                GIT_COMMAND_TIMEOUT_MS,
12236            )
12237            .await
12238            {
12239                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12240                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12241                    ContextPackRepositoryHead::Unborn
12242                }
12243                Err(_) => {
12244                    return Err(failure(
12245                        NodeFailureCode::ContextPackMaterializationFailed,
12246                        "context pack HEAD observation failed",
12247                    ));
12248                }
12249            }
12250        } else {
12251            ContextPackRepositoryHead::NotRepository
12252        };
12253        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12254        for selected_path in CONTEXT_PACK_SELECTED_FILES {
12255            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12256                failure(
12257                    NodeFailureCode::InvalidRepositoryPath,
12258                    "context pack selected file path is invalid",
12259                )
12260            })?;
12261            match self.read_workspace_file(workspace_id.clone(), path).await {
12262                Ok(WorkspaceFileRead {
12263                    content: WorkspaceFileContent::Utf8 { text, byte_len },
12264                    ..
12265                }) => files.push(ContextPackRepositoryFileSource::utf8(
12266                    selected_path,
12267                    text,
12268                    byte_len,
12269                )),
12270                Ok(WorkspaceFileRead {
12271                    content: WorkspaceFileContent::NonUtf8 { byte_len },
12272                    ..
12273                }) => files.push(ContextPackRepositoryFileSource::skipped(
12274                    selected_path,
12275                    ContextPackSelectedFileSkipReason::NonUtf8,
12276                    Some(byte_len),
12277                )),
12278                Ok(WorkspaceFileRead {
12279                    content: WorkspaceFileContent::TooLarge { .. },
12280                    ..
12281                }) => files.push(ContextPackRepositoryFileSource::skipped(
12282                    selected_path,
12283                    ContextPackSelectedFileSkipReason::TooLarge,
12284                    None,
12285                )),
12286                Err(error) => {
12287                    let reason = if matches!(
12288                        error.code,
12289                        NodeFailureCode::InvalidRepositoryPath
12290                            | NodeFailureCode::RepositoryFileNotRegular
12291                            | NodeFailureCode::RepositoryPathUnsafe
12292                    ) {
12293                        ContextPackSelectedFileSkipReason::Unsafe
12294                    } else {
12295                        ContextPackSelectedFileSkipReason::Unavailable
12296                    };
12297                    files.push(ContextPackRepositoryFileSource::skipped(
12298                        selected_path,
12299                        reason,
12300                        None,
12301                    ));
12302                }
12303            }
12304        }
12305        Ok(ContextPackRepositoryObservation { head, git, files })
12306    }
12307
12308    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12309        let referenced_by_binding = self
12310            .session_bindings
12311            .lock()
12312            .unwrap_or_else(|poisoned| poisoned.into_inner())
12313            .values()
12314            .any(|binding| {
12315                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12316            });
12317        let referenced_by_record = self
12318            .session_records
12319            .lock()
12320            .unwrap_or_else(|poisoned| poisoned.into_inner())
12321            .records
12322            .values()
12323            .any(|record| record.context_id.as_ref() == Some(context_id));
12324        let referenced_by_materialization = self
12325            .materializations
12326            .lock()
12327            .unwrap_or_else(|poisoned| poisoned.into_inner())
12328            .values()
12329            .any(|ownership| {
12330                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12331            });
12332        if referenced_by_binding
12333            || referenced_by_record
12334            || referenced_by_materialization
12335        {
12336            return Err(failure(
12337                NodeFailureCode::ContextPackBusy,
12338                "context pack is still referenced",
12339            ));
12340        }
12341        let mut catalog = self.context_catalog
12342            .write()
12343            .unwrap_or_else(|poisoned| poisoned.into_inner());
12344        if catalog.get(context_id).is_none() {
12345            return Err(failure(
12346                NodeFailureCode::UnknownContextPack,
12347                "context pack is unavailable",
12348            ));
12349        }
12350        self.spawn_idempotency
12351            .lock()
12352            .unwrap_or_else(|poisoned| poisoned.into_inner())
12353            .entries
12354            .retain(|_, entry| !entry.value.references_context(context_id));
12355        let removed = catalog.remove(context_id);
12356        debug_assert!(removed.is_some(), "validated context pack remains present");
12357        Ok(())
12358    }
12359
12360    async fn dispatch_input_bounded(
12361        &self,
12362        address: &SessionAddress,
12363        action: InputAction,
12364    ) -> Result<(), NodeFailure> {
12365        let started_after = self.current_sequence();
12366        self.pty_input_marks
12367            .lock()
12368            .unwrap_or_else(|poisoned| poisoned.into_inner())
12369            .insert(address.session.instance_id, (Instant::now(), false));
12370        self.pty_input_wake.notify_one();
12371        let command_id = self
12372            .dispatch_bounded(
12373                ControlCommand::SendInput {
12374                    instance_id: address.session.instance_id,
12375                    action,
12376                },
12377                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12378            )
12379            .await?;
12380        // A semantic prompt may spend the provider's full readiness budget before
12381        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12382        // but strictly beyond every readiness policy in this node's catalog.
12383        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12384        let mut scan_after = started_after;
12385        let mut accepted_after = None;
12386        loop {
12387            let events = self
12388                .history
12389                .lock()
12390                .unwrap_or_else(|poisoned| poisoned.into_inner())
12391                .events
12392                .iter()
12393                .filter(|envelope| envelope.sequence > scan_after)
12394                .cloned()
12395                .collect::<Vec<_>>();
12396            for envelope in events {
12397                scan_after = scan_after.max(envelope.sequence);
12398                let NodeEvent::Control {
12399                    address: event_address,
12400                    event,
12401                } = envelope.event
12402                else {
12403                    continue;
12404                };
12405                if event_address != *address {
12406                    continue;
12407                }
12408                if event.command_id == Some(command_id) {
12409                    match &event.event {
12410                        ControlEventKind::CommandRejected { message } => {
12411                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12412                        }
12413                        ControlEventKind::InputRequested { .. } => {
12414                            accepted_after = Some(envelope.sequence);
12415                            continue;
12416                        }
12417                        _ => {}
12418                    }
12419                }
12420                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12421                    match &event.event {
12422                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12423                        ControlEventKind::InputFailed { message, .. } => {
12424                            return Err(failure(
12425                                NodeFailureCode::BackendOperationFailed,
12426                                message,
12427                            ));
12428                        }
12429                        _ => {}
12430                    }
12431                }
12432            }
12433            if Instant::now() >= deadline {
12434                return Err(failure(
12435                    NodeFailureCode::BackendBusy,
12436                    if accepted_after.is_some() {
12437                        "input was accepted but did not settle before the bounded deadline"
12438                    } else {
12439                        "input was not accepted before the bounded deadline"
12440                    },
12441                ));
12442            }
12443            sleep(Duration::from_millis(2)).await;
12444        }
12445    }
12446
12447    async fn dispatch_resize_bounded(
12448        &self,
12449        address: &SessionAddress,
12450        size: gate4agent_types::TerminalSize,
12451    ) -> Result<(), NodeFailure> {
12452        let started_after = self.current_sequence();
12453        let command_id = self
12454            .dispatch_bounded(
12455                ControlCommand::Resize {
12456                    instance_id: address.session.instance_id,
12457                    size,
12458                },
12459                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12460            )
12461            .await?;
12462        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12463        let mut scan_after = started_after;
12464        let mut accepted_after = None;
12465        loop {
12466            let events = self
12467                .history
12468                .lock()
12469                .unwrap_or_else(|poisoned| poisoned.into_inner())
12470                .events
12471                .iter()
12472                .filter(|envelope| envelope.sequence > scan_after)
12473                .cloned()
12474                .collect::<Vec<_>>();
12475            for envelope in events {
12476                scan_after = scan_after.max(envelope.sequence);
12477                let NodeEvent::Control {
12478                    address: event_address,
12479                    event,
12480                } = envelope.event
12481                else {
12482                    continue;
12483                };
12484                if event_address != *address {
12485                    continue;
12486                }
12487                if event.command_id == Some(command_id) {
12488                    match &event.event {
12489                        ControlEventKind::CommandRejected { message } => {
12490                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12491                        }
12492                        ControlEventKind::ResizeRequested {
12493                            size: requested_size,
12494                            ..
12495                        } if *requested_size == size => {
12496                            accepted_after = Some(envelope.sequence);
12497                            continue;
12498                        }
12499                        _ => {}
12500                    }
12501                }
12502                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12503                    match &event.event {
12504                        ControlEventKind::Resized { size: settled_size }
12505                            if *settled_size == size =>
12506                        {
12507                            return Ok(());
12508                        }
12509                        ControlEventKind::ResizeFailed { message } => {
12510                            return Err(failure(
12511                                NodeFailureCode::BackendOperationFailed,
12512                                message,
12513                            ));
12514                        }
12515                        _ => {}
12516                    }
12517                }
12518            }
12519            if Instant::now() >= deadline {
12520                return Err(failure(
12521                    NodeFailureCode::BackendBusy,
12522                    if accepted_after.is_some() {
12523                        "resize was accepted but did not settle before the bounded deadline"
12524                    } else {
12525                        "resize was not accepted before the bounded deadline"
12526                    },
12527                ));
12528            }
12529            sleep(Duration::from_millis(2)).await;
12530        }
12531    }
12532
12533    async fn spawn_session(
12534        &self,
12535        workspace_id: WorkspaceId,
12536        provider: AgentId,
12537        mode: SessionMode,
12538        terminal_size: gate4agent_types::TerminalSize,
12539        initial_prompt: Option<String>,
12540    ) -> Result<SessionAddress, NodeFailure> {
12541        if self.managed_worktrees.lock()
12542            .unwrap_or_else(|poisoned| poisoned.into_inner())
12543            .lease_for_workspace(&workspace_id)
12544            .is_some()
12545        {
12546            return Err(failure(
12547                NodeFailureCode::ManagedWorktreeOwnershipConflict,
12548                "managed worktree targets may only be spawned through their lease request",
12549            ));
12550        }
12551        self
12552            .spawn_session_with_deadline(
12553                workspace_id,
12554                provider,
12555                mode,
12556                terminal_size,
12557                initial_prompt,
12558                None,
12559                None,
12560                None,
12561                None,
12562                None,
12563                SpawnRecordPolicy::ProviderIdentityOnly,
12564                None,
12565                &[],
12566                None,
12567                // `NodeRequest::Spawn` (this method's only caller) carries no
12568                // approval-level selection of its own -- see
12569                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
12570                // the one operator-wire path that can choose something other
12571                // than the axis default.
12572                ApprovalLevel::default(),
12573                None,
12574                None,
12575            )
12576            .await
12577            .map(|(session, _runtime_policy)| session)
12578    }
12579
12580    async fn spawn_session_with_deadline(
12581        &self,
12582        workspace_id: WorkspaceId,
12583        provider: AgentId,
12584        mode: SessionMode,
12585        terminal_size: gate4agent_types::TerminalSize,
12586        initial_prompt: Option<String>,
12587        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12588        bundle: Option<ResolvedBundleReceipt>,
12589        context: Option<ResolvedContextPackReceipt>,
12590        managed_authority: Option<ManagedWorktreeLeaseId>,
12591        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12592        record_policy: SpawnRecordPolicy,
12593        deadline: Option<Instant>,
12594        required_capabilities: &[ProviderRuntimeCapability],
12595        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12596        approval_level: ApprovalLevel,
12597        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12598        // Opaque dig2browser station profile id (ids only). Feature
12599        // `dig2-station-probe`: after probe at resolve, spawn takes an
12600        // exclusive node-local lease; cleared on session end. Never
12601        // ImportSession / cookies on C2. Feature off: ignored (stub echo
12602        // already happened on the receipt path).
12603        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12604    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12605        let reserved_lease = self.managed_worktrees.lock()
12606            .unwrap_or_else(|poisoned| poisoned.into_inner())
12607            .lease_for_workspace(&workspace_id);
12608        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12609            (Some(expected), Some(provided)) if expected == provided => {}
12610            (None, None) => {}
12611            _ => {
12612                return Err(failure(
12613                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
12614                    "spawn lacks exact managed worktree lease authority",
12615                ));
12616            }
12617        }
12618        // Refuse concurrent distinct environment profiles (incl. ProviderHome
12619        // bindings) on the same workspace cwd. Parallel path: managed worktree
12620        // spawn so each profile gets its own workspace_id.
12621        self.ensure_workspace_environment_profile_compatible(
12622            &workspace_id,
12623            environment_profile.as_ref(),
12624        )?;
12625        let runtime_requirement = match (mode, initial_prompt.is_some()) {
12626            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12627            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12628            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12629            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12630        };
12631        // This session's own transport -- computed once, here, from `mode`,
12632        // and reused both to admit spawn (below) and, further down, to
12633        // compute the final `runtime_policy` this session actually operates
12634        // under (`provider_runtime::policy_for_transport`). Registration
12635        // reuses this exact value rather than recomputing it a second time.
12636        let transport = match mode {
12637            SessionMode::Pty => TransportKind::Pty,
12638            SessionMode::Inline => TransportKind::Pipe,
12639            SessionMode::Acp => TransportKind::Acp,
12640        };
12641        let pipe_transport = self.provider_supports_pipe_transport(&provider);
12642        let acp_transport = self.provider_supports_acp_transport(&provider);
12643        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12644            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12645                NodeFailureCode::UnsupportedSpawnCapability,
12646                "pre-admitted provider runtime no longer satisfies spawn requirements",
12647            ))?;
12648            runtime_policy
12649        } else if let Some(deadline) = deadline {
12650            let remaining = spawn_deadline_remaining(deadline)?;
12651            timeout(
12652                remaining,
12653                self.admit_provider_runtime(&provider, runtime_requirement),
12654            )
12655            .await
12656            .map_err(|_| {
12657                failure(
12658                    NodeFailureCode::SpawnDeadlineExceeded,
12659                    "provider admission exceeded the spawn deadline",
12660                )
12661            })??
12662        } else {
12663            self.admit_provider_runtime(&provider, runtime_requirement).await?
12664        };
12665        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12666        // The single point where this session's `runtime_policy` becomes
12667        // transport-correct: `admit_provider_runtime` (and the pre-admitted
12668        // branch above) only ever compute a PTY-terminal-verification-shaped
12669        // value, sound outright only for `TransportKind::Pty` (Pipe still
12670        // relies on that same value downstream -- see `policy_for_transport`'s
12671        // own doc for why). `policy_for_transport` is the explicit,
12672        // exhaustive match Item 1 asks for -- this is the ONE place its
12673        // output becomes the session's `runtime_policy`, so every later
12674        // reader (`ControlCommand::Start`, `session_bindings`, and every
12675        // engine gate that reads `state.runtime_policy`) sees the same,
12676        // correctly-computed value.
12677        let runtime_policy =
12678            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12679        if required_capabilities
12680            .iter()
12681            .any(|capability| !runtime_policy.admits(*capability))
12682        {
12683            return Err(failure(
12684                NodeFailureCode::UnsupportedSpawnCapability,
12685                "provider runtime does not admit a required spawn capability",
12686            ));
12687        }
12688        if let Some(deadline) = deadline {
12689            spawn_deadline_remaining(deadline)?;
12690        }
12691        self.ensure_binding_capacity()?;
12692        let working_directory = self.workspace_root(&workspace_id)?;
12693        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12694        let session = SessionKey {
12695            instance_id,
12696            generation: SessionGeneration(1),
12697        };
12698        let address = SessionAddress {
12699            workspace_id: workspace_id.clone(),
12700            session,
12701        };
12702        // This is the request-accepted checkpoint: admission (managed
12703        // worktree authority, runtime capability, dispatch deadline) already
12704        // passed and a concrete session identity is minted. It is a distinct
12705        // state from the process actually running (`ControlEventKind::Running`,
12706        // logged in `publish_control`) or the terminal outcome (`Exited`/
12707        // `Failed`, logged there too) -- this line exists so that gap is no
12708        // longer silent.
12709        tracing::info!(
12710            node_id = %self.node_id,
12711            workspace_id = %workspace_id,
12712            provider = %provider,
12713            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12714            session = ?address.session,
12715            "session spawn request accepted",
12716        );
12717        let mut prepared_spawn = SpawnPreparer::prepare(
12718            self,
12719            SpawnPrepareRequest {
12720                instance_id,
12721                address: &address,
12722                provider: &provider,
12723                mode,
12724                environment_profile: environment_profile.as_ref(),
12725                bundle: bundle.as_ref(),
12726                context: context.as_ref(),
12727                managed_authority: managed_authority.as_ref(),
12728                harness_mcp,
12729                approval_level,
12730                network_allowlist: network_allowlist.as_ref(),
12731                browser_profile_id,
12732            },
12733        )?;
12734        let materialization_id = prepared_spawn.materialization_id.take();
12735        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12736        let record_id = self.bind_spawn_session_with_materialization(
12737            &address,
12738            provider.clone(),
12739            mode,
12740            runtime_policy,
12741            record_policy,
12742            environment_profile,
12743            bundle,
12744            context,
12745            materialization_id,
12746        )?;
12747        prepared_spawn.retain_session_guards();
12748        // `transport` was already computed above, alongside this session's
12749        // `runtime_policy`; reused here for `Register` rather than derived a
12750        // second time.
12751        let agent_id = provider;
12752        let register_command_id = match self.dispatch_bounded(
12753            ControlCommand::Register {
12754                instance_id,
12755                agent_id,
12756                transport,
12757            },
12758            dispatch_timeout,
12759        )
12760        .await {
12761            Ok(command_id) => command_id,
12762            Err(error) => {
12763                if let Some(binding) = self.remove_binding(&address) {
12764                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12765                }
12766                if let Some(record_id) = record_id.as_ref() {
12767                    self.discard_record(record_id)?;
12768                }
12769                return Err(spawn_dispatch_error(error, deadline));
12770            }
12771        };
12772        let start_timeout = match spawn_dispatch_timeout(deadline) {
12773            Ok(timeout) => timeout,
12774            Err(error) => {
12775                let recovery = self
12776                    .rollback_spawn(
12777                        &address,
12778                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12779                    )
12780                    .await;
12781                return match recovery {
12782                    Ok(()) => {
12783                        if let Some(record_id) = record_id.as_ref() {
12784                            self.discard_record(record_id)?;
12785                        }
12786                        Err(error)
12787                    }
12788                    Err(recovery_error) => {
12789                        if let Some(record_id) = record_id.as_ref() {
12790                            self.mark_record_error(
12791                                record_id,
12792                                "spawn deadline elapsed and registration recovery failed",
12793                            )?;
12794                        }
12795                        Err(recovery_error)
12796                    }
12797                };
12798            }
12799        };
12800        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12801            if let Err(recovery_error) = self
12802                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12803                .await
12804            {
12805                return Err(recovery_error);
12806            }
12807            if let Some(record_id) = record_id.as_ref() {
12808                self.discard_record(record_id)?;
12809            }
12810            return Err(start_error);
12811        }
12812        let start_result = self
12813            .dispatch_bounded(
12814                ControlCommand::Start {
12815                    instance_id,
12816                    runtime_policy,
12817                    request: StartRequest {
12818                        working_directory,
12819                        terminal_size,
12820                        initial_prompt,
12821                        session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
12822                        approval_level,
12823                    },
12824                },
12825                start_timeout,
12826            )
12827            .await;
12828        let start_command_id = match start_result {
12829            Ok(command_id) => command_id,
12830            Err(start_error) => {
12831                let start_error = spawn_dispatch_error(start_error, deadline);
12832                let recovery = self
12833                    .rollback_spawn(
12834                        &address,
12835                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12836                    )
12837                    .await;
12838                return match recovery {
12839                    Ok(()) => {
12840                        if let Some(record_id) = record_id.as_ref() {
12841                            self.discard_record(record_id)?;
12842                        }
12843                        Err(start_error)
12844                    }
12845                    Err(recovery_error) => {
12846                        let diagnostic = format!(
12847                            "start failed and registration recovery failed: {}",
12848                            recovery_error.message,
12849                        );
12850                        if let Some(record_id) = record_id.as_ref() {
12851                            self.mark_record_error(record_id, &diagnostic)?;
12852                        }
12853                        Err(failure(recovery_error.code, &diagnostic))
12854                    }
12855                };
12856            }
12857        };
12858
12859        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12860        loop {
12861            // `Register`/`Start` only fail synchronously for a port-level
12862            // ingress problem (see `dispatch_bounded`); the kernel refusing
12863            // either command's content -- an unsupported transport, an
12864            // unknown agent -- lands one drive-loop tick later, and no
12865            // session ever appears in `self.handle.snapshot()` for it to
12866            // notice below. Check for that named reason before falling
12867            // through to the blind presence poll, so a rejected spawn
12868            // reports why instead of just timing out.
12869            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12870                .or_else(|| self.take_rejected_command_reason(start_command_id))
12871            {
12872                self.rollback_spawn(
12873                    &address,
12874                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12875                ).await.map_err(|error| {
12876                        failure(
12877                            error.code,
12878                            &format!(
12879                                "spawn was rejected ({}) and registration recovery failed: {}",
12880                                reason.message, error.message
12881                            ),
12882                        )
12883                    })?;
12884                if let Some(record_id) = record_id.as_ref() {
12885                    self.discard_record(record_id)?;
12886                }
12887                let code = if reason.unsupported_transport {
12888                    NodeFailureCode::UnsupportedTransport
12889                } else {
12890                    NodeFailureCode::BackendOperationFailed
12891                };
12892                return Err(failure(code, &reason.message));
12893            }
12894            if let Some(current) = self
12895                .handle
12896                .snapshot()
12897                .sessions
12898                .iter()
12899                .find(|current| current.instance_id == instance_id)
12900            {
12901                if current.generation != session.generation {
12902                    let divergence = failure(
12903                        NodeFailureCode::StaleGeneration,
12904                        "spawn generation diverged",
12905                    );
12906                    return match self
12907                        .rollback_spawn(
12908                            &address,
12909                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12910                        )
12911                        .await
12912                    {
12913                        Ok(()) => {
12914                            if let Some(record_id) = record_id.as_ref() {
12915                                self.discard_record(record_id)?;
12916                            }
12917                            Err(divergence)
12918                        }
12919                        Err(recovery_error) => {
12920                            if let Some(record_id) = record_id.as_ref() {
12921                                self.mark_record_error(
12922                                    record_id,
12923                                    "spawn generation diverged and recovery failed",
12924                                )?;
12925                            }
12926                            Err(recovery_error)
12927                        }
12928                    };
12929                }
12930                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12931                    prepared_spawn.retain_launch_guards();
12932                    return Ok((address, runtime_policy));
12933                }
12934            }
12935            if Instant::now() >= commit_deadline {
12936                self.rollback_spawn(
12937                    &address,
12938                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12939                ).await.map_err(|error| {
12940                        failure(
12941                            error.code,
12942                            &format!(
12943                                "start did not commit and registration recovery failed: {}",
12944                                error.message
12945                            ),
12946                        )
12947                    })?;
12948                if let Some(record_id) = record_id.as_ref() {
12949                    self.discard_record(record_id)?;
12950                }
12951                return Err(failure(
12952                    if deadline.is_some() {
12953                        NodeFailureCode::SpawnDeadlineExceeded
12954                    } else {
12955                        NodeFailureCode::BackendBusy
12956                    },
12957                    "start did not commit before the bounded deadline; registration was removed",
12958                ));
12959            }
12960            sleep(Duration::from_millis(2)).await;
12961        }
12962    }
12963
12964    fn effective_spawn_runtime_policy(
12965        &self,
12966        runtime_policy: ProviderRuntimePolicy,
12967    ) -> ProviderRuntimePolicy {
12968        #[cfg(feature = "fixture")]
12969        let runtime_policy = if self.fixture_semantic_hook_policy {
12970            ProviderRuntimePolicy::new(true, true, true, true, false, true)
12971                .expect("monitoring Hook fixture policy is internally valid")
12972        } else {
12973            runtime_policy
12974        };
12975        runtime_policy
12976    }
12977
12978    fn verify_harness_mcp_before_start(
12979        &self,
12980        prepared: Option<&PreparedHarnessMcpSpawn>,
12981    ) -> Result<(), NodeFailure> {
12982        prepared
12983            .map(PreparedHarnessMcpSpawn::verify_helper_program)
12984            .transpose()
12985            .map(|_| ())
12986            .map_err(harness_mcp_failure)
12987    }
12988
12989    async fn rollback_spawn(
12990        &self,
12991        address: &SessionAddress,
12992        commit_timeout: Duration,
12993    ) -> Result<(), NodeFailure> {
12994        self.dispatch_bounded(
12995            ControlCommand::Remove {
12996                instance_id: address.session.instance_id,
12997            },
12998            commit_timeout,
12999        )
13000        .await?;
13001        self.wait_until_removed(address, commit_timeout).await?;
13002        if let Some(binding) = self.remove_binding(address) {
13003            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13004        }
13005        Ok(())
13006    }
13007
13008    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13009        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13010        self.dispatch_bounded(
13011            ControlCommand::Remove {
13012                instance_id: address.session.instance_id,
13013            },
13014            commit_timeout,
13015        )
13016            .await?;
13017        self.wait_until_removed(address, commit_timeout).await?;
13018        let removed = self.remove_binding(address);
13019        if let Some(binding) = removed.as_ref() {
13020            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13021        }
13022        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13023            let snapshot = self.managed_worktrees.lock()
13024                .unwrap_or_else(|poisoned| poisoned.into_inner())
13025                .release_session(
13026                    &lease_id,
13027                    self.incarnation_id,
13028                    address.session.instance_id,
13029                    address.session.generation,
13030                    unix_time_ms(),
13031                );
13032            if self.persist_state().is_err() {
13033                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13034            }
13035            if let Some(snapshot) = snapshot {
13036                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13037            }
13038            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13039        }
13040        Ok(())
13041    }
13042
13043    /// Polls until `address`'s session leaves the engine's own session map,
13044    /// or the bounded deadline expires.
13045    ///
13046    /// A `Remove` sent while the session is not yet in a terminal status
13047    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
13048    /// once by the engine (`SessionStatus::allows_remove`) and
13049    /// then discarded -- nothing re-issues it on its own, so the removal
13050    /// that would satisfy this wait never arrives and the caller was
13051    /// guaranteed to time out even though the session settles microseconds
13052    /// later. So this re-dispatches on every poll tick: a session that
13053    /// settles mid-wait -- the common case right after a `Stop` -- still
13054    /// gets removed inside the same bounded deadline.
13055    ///
13056    /// It re-dispatches only once the status will ACCEPT the command. A
13057    /// rejected re-dispatch is not the harmless no-op this loop first
13058    /// assumed: every rejection publishes a `ControlEventKind::Command
13059    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
13060    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
13061    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
13062    /// out to the harness and every attached client. Gated, the same
13063    /// teardown sends exactly one `Remove`, at the first tick the engine
13064    /// can act on it.
13065    ///
13066    /// The status is read from the snapshot this loop already takes, so
13067    /// the gate costs nothing extra. It races -- the status can move
13068    /// between the read and the dispatch -- and that is fine: a rare lost
13069    /// race is one rejection, and the next tick retries.
13070    ///
13071    /// Re-dispatch is separately rate-limited to
13072    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
13073    /// leaves a second burst behind it. Once the session settles, every
13074    /// 2ms tick sent another `Remove` while the accepted one was still in
13075    /// flight, and each of those landed on an instance the engine had
13076    /// already dropped -- `agent instance … is not registered`, observed
13077    /// live as a tail of rejections immediately after the `session
13078    /// removed` line. The presence poll stays at 2ms so the wait still
13079    /// exits promptly; only the sending slows down.
13080    ///
13081    /// Rate-limited rather than once-only on purpose: the status gate
13082    /// covers the status precondition, but `Gate4AgentEngine::remove` has
13083    /// others (a pending operation, an in-flight capability probe or
13084    /// history request) that clear on their own. A single shot would turn
13085    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
13086    /// get their retries, just twenty of them across the deadline instead
13087    /// of a thousand.
13088    async fn wait_until_removed(
13089        &self,
13090        address: &SessionAddress,
13091        commit_timeout: Duration,
13092    ) -> Result<(), NodeFailure> {
13093        let deadline = Instant::now() + commit_timeout;
13094        let mut next_dispatch = Instant::now();
13095        loop {
13096            let snapshot = self.handle.snapshot();
13097            let status = snapshot
13098                .sessions
13099                .iter()
13100                .find(|session| session.instance_id == address.session.instance_id)
13101                .map(|session| session.status.clone());
13102            let Some(status) = status else {
13103                return Ok(());
13104            };
13105            let now = Instant::now();
13106            if now >= deadline {
13107                return Err(failure(
13108                    NodeFailureCode::BackendBusy,
13109                    "remove did not commit before the bounded deadline; session binding was retained",
13110                ));
13111            }
13112            if status.allows_remove() && now >= next_dispatch {
13113                let _ = self.dispatch(ControlCommand::Remove {
13114                    instance_id: address.session.instance_id,
13115                });
13116                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13117            }
13118            sleep(Duration::from_millis(2)).await;
13119        }
13120    }
13121
13122    fn resync(&self, after_sequence: u64) -> NodeResponse {
13123        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13124        let event_sequence = history.last_sequence;
13125        let oldest_available_sequence = history.replay_floor_sequence;
13126        let events = history
13127            .events
13128            .iter()
13129            .filter(|event| event.sequence > after_sequence)
13130            .cloned()
13131            .collect();
13132        drop(history);
13133        NodeResponse::Resync {
13134            event_sequence,
13135            oldest_available_sequence,
13136            snapshot: self.snapshot(),
13137            events,
13138        }
13139    }
13140}
13141
13142/// Coarse label for one `AgentStreamChunkKindV1`, used only by
13143/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
13144/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
13145/// "name what crossed this boundary" reasoning, one layer upstream of it).
13146fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13147    match kind {
13148        AgentStreamChunkKindV1::Text { .. } => "text",
13149        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13150        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13151        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13152        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13153        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13154        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13155    }
13156}
13157
13158/// Serves this wire on the local endpoint, and -- when `call_home` names a
13159/// relay -- over an outbound connection to it as well.
13160///
13161/// The two live in one future rather than a fourth arm of `run`'s own
13162/// `select!` because they are one concern: this is where the node's wire
13163/// is served, and the direction a given connection was opened in does not
13164/// change what happens on it. It also means the existing graceful drain
13165/// below covers both without being duplicated.
13166///
13167/// Either side finishing ends the pair, which is correct: the listener
13168/// only returns on shutdown or a bind failure, and `call_home_loop` only
13169/// returns on shutdown -- a failed relay connection is retried inside it,
13170/// never propagated out.
13171async fn accept_connections(
13172    endpoint: &str,
13173    call_home: Option<std::net::SocketAddr>,
13174    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13175    shared: Arc<NodeShared>,
13176) -> Result<(), NodeServerError> {
13177    let mut connections = JoinSet::new();
13178    let result = match call_home {
13179        None => accept_connections_inner(endpoint, shared, &mut connections).await,
13180        Some(relay) => {
13181            let listener =
13182                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13183            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13184            tokio::pin!(listener);
13185            tokio::pin!(dialler);
13186            tokio::select! {
13187                result = &mut listener => result,
13188                result = &mut dialler => result,
13189            }
13190        }
13191    };
13192    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13193    while !connections.is_empty() {
13194        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13195        if remaining.is_zero()
13196            || timeout(remaining, connections.join_next()).await.is_err()
13197        {
13198            break;
13199        }
13200    }
13201    connections.abort_all();
13202    while connections.join_next().await.is_some() {}
13203    result
13204}
13205
13206/// Backoff ladder for a relay that will not answer, capped rather than
13207/// unbounded: a node whose relay is down is not broken and should keep
13208/// trying forever, but it should not spend a core doing it.
13209const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13210
13211/// Dials the relay, serves this wire over the connection, and does it
13212/// again when the connection ends.
13213///
13214/// The node is the SERVER on the socket it just opened. That inversion is
13215/// the whole point: `serve_connection` is the same function the local
13216/// listener feeds, and it neither knows nor cares which end placed the
13217/// call. The one thing this direction adds is the preface -- the relay
13218/// accepted a socket and cannot know whose it is, so the node says its
13219/// name before the handshake it would otherwise begin with.
13220///
13221/// Never returns an error. A relay that is down, refusing, or speaking a
13222/// different protocol is a condition to retry, not a reason to take the
13223/// node's local endpoint down with it; the only way out is shutdown.
13224async fn call_home_loop(
13225    relay: std::net::SocketAddr,
13226    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13227    shared: Arc<NodeShared>,
13228) -> Result<(), NodeServerError> {
13229    let mut failures = 0_usize;
13230    loop {
13231        if shared.shutdown.load(Ordering::Acquire) {
13232            return Ok(());
13233        }
13234        let permit = tokio::select! {
13235            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13236                permit.map_err(|_| NodeServerError::PreauthClosed)?
13237            }
13238            _ = shared.shutdown_notify.notified() => return Ok(()),
13239        };
13240        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13241            Ok(()) => {
13242                // A clean end is the relay hanging up, which it does on
13243                // its own restart. Reconnect immediately rather than
13244                // serving the backoff a peer that is plainly there.
13245                failures = 0;
13246                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13247            }
13248            Err(error) => {
13249                failures = failures.saturating_add(1);
13250                tracing::warn!(
13251                    node_id = %shared.node_id,
13252                    %relay,
13253                    cause = %error,
13254                    attempt = failures,
13255                    "call-home connection failed",
13256                );
13257                let backoff = call_home_backoff(failures);
13258                tokio::select! {
13259                    _ = sleep(backoff) => {}
13260                    _ = shared.shutdown_notify.notified() => return Ok(()),
13261                }
13262            }
13263        }
13264    }
13265}
13266
13267/// Idle time before the first keepalive probe on a call-home socket.
13268const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13269/// Interval between keepalive probes once the link has gone quiet.
13270const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13271
13272fn call_home_keepalive() -> socket2::TcpKeepalive {
13273    let keepalive = socket2::TcpKeepalive::new()
13274        .with_time(CALL_HOME_KEEPALIVE_IDLE)
13275        .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13276    #[cfg(not(windows))]
13277    let keepalive = keepalive.with_retries(3);
13278    keepalive
13279}
13280
13281fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13282    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13283}
13284
13285/// Same keepalive profile for any node->relay socket (restream uplink).
13286pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13287    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13288}
13289
13290/// Backoff for the `attempt`-th consecutive failure (1-based), capped.
13291fn call_home_backoff(attempt: usize) -> Duration {
13292    Duration::from_millis(
13293        CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13294    )
13295}
13296
13297/// One call-home attempt: connect, name this node, then serve.
13298async fn dial_relay_and_serve(
13299    relay: std::net::SocketAddr,
13300    identity: Option<&hatchery_enroll::NodeIdentity>,
13301    shared: Arc<NodeShared>,
13302    preauth_permit: OwnedSemaphorePermit,
13303) -> Result<(), NodeServerError> {
13304    let mut stream = tokio::net::TcpStream::connect(relay)
13305        .await
13306        .map_err(NodeServerError::CallHomeTransport)?;
13307    // Nagle off for the same reason the rest of this wire wants it off:
13308    // the traffic is small request/response frames, and coalescing them
13309    // adds latency to every one.
13310    let _ = stream.set_nodelay(true);
13311    // A relay that vanishes without a FIN/RST reaching us (tunnel drop,
13312    // relay-side socket kill whose RST was lost) would otherwise leave this
13313    // socket ESTABLISHED forever: the node mostly reads, so nothing ever
13314    // fails and the reconnect loop never runs. Kernel keepalive turns that
13315    // half-open link into a read error within a bounded time.
13316    if let Err(error) = enable_call_home_keepalive(&stream) {
13317        tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13318    }
13319    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13320        .await
13321        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13322    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13323    let mut session_token = None;
13324    if let Some(identity) = identity {
13325        // An enrolled relay answers the announce with a signed-call-home challenge; a
13326        // token-mode relay sends its wire hello instead. Peek to tell them apart
13327        // without consuming either.
13328        let mut magic = [0_u8; 4];
13329        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13330            .await
13331            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13332            .map_err(NodeServerError::CallHomeTransport)?;
13333        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13334            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13335            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13336                .await
13337                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13338                .map_err(NodeServerError::CallHomeTransport)?;
13339            let (answer, token) = identity
13340                .answer_challenge(shared.node_id.as_str(), &frame)
13341                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13342            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13343            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13344            session_token = Some(token);
13345        }
13346    }
13347    serve_connection(stream, shared, session_token, preauth_permit).await
13348}
13349
13350async fn accept_connections_inner(
13351    endpoint: &str,
13352    shared: Arc<NodeShared>,
13353    connections: &mut JoinSet<Result<(), NodeServerError>>,
13354) -> Result<(), NodeServerError> {
13355    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13356    loop {
13357        while connections.try_join_next().is_some() {}
13358        if shared.shutdown.load(Ordering::Acquire) {
13359            return Ok(());
13360        }
13361        let preauth_permit = tokio::select! {
13362            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13363                permit.map_err(|_| NodeServerError::PreauthClosed)?
13364            }
13365            _ = shared.shutdown_notify.notified() => return Ok(()),
13366        };
13367        let server = tokio::select! {
13368            result = listener.accept() => result?,
13369            _ = shared.shutdown_notify.notified() => return Ok(()),
13370        };
13371        let connection_shared = Arc::clone(&shared);
13372        connections.spawn(async move {
13373            serve_connection(server, connection_shared, None, preauth_permit).await
13374        });
13375        if shared.shutdown.load(Ordering::Acquire) {
13376            return Ok(());
13377        }
13378    }
13379}
13380
13381/// Serves one connection, whichever end opened it.
13382///
13383/// Generic over the stream rather than typed to `LocalServerStream`
13384/// because a call-home connection arrives on a `TcpStream` the node itself
13385/// dialled. Nothing in the body ever depended on the concrete type -- it
13386/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13387/// the whole of what welding "who dials" to "who serves" was costing.
13388async fn serve_connection<S>(
13389    mut pipe: S,
13390    shared: Arc<NodeShared>,
13391    session_token: Option<String>,
13392    preauth_permit: OwnedSemaphorePermit,
13393) -> Result<(), NodeServerError>
13394where
13395    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13396{
13397    // A signed call-home connection authenticates with its own per-connection token.
13398    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13399    let hello_frame = timeout(
13400        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13401        read_json_frame_limited_body_timeout(
13402            &mut pipe,
13403            MAX_NODE_HELLO_FRAME_BYTES,
13404            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13405        ),
13406    )
13407    .await
13408    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13409    let ClientFrame::Hello(hello) = hello_frame else {
13410        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13411    };
13412    if hello.build_stamp != BUILD_STAMP {
13413        return Err(NodeServerError::Handshake(format!(
13414            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13415            hello.build_stamp,
13416        )));
13417    }
13418    let compatibility = match hello.compatibility.as_ref() {
13419        Some(offer) => {
13420            let mut selected = node_compatibility_support(&shared)?
13421                .negotiate(offer)
13422                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13423            project_negotiated_provider_ids(&mut selected);
13424            Some(selected)
13425        }
13426        None => None,
13427    };
13428    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13429    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13430        (Some(offer), Some(selected)) => negotiated_auth_proof(
13431            auth_token,
13432            AuthDirection::Server,
13433            hello.role,
13434            &hello.client_nonce,
13435            &server_nonce,
13436            offer,
13437            selected,
13438        ),
13439        (None, None) => auth_proof(
13440            auth_token,
13441            AuthDirection::Server,
13442            hello.role,
13443            &hello.client_nonce,
13444            &server_nonce,
13445        ),
13446        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13447    }
13448    .map_err(NodeServerError::Authentication)?;
13449    write_json_frame_limited(
13450        &mut pipe,
13451        &ServerFrame::Challenge(ServerChallenge {
13452            build_stamp: BUILD_STAMP.to_owned(),
13453            server_nonce,
13454            server_proof,
13455            compatibility: compatibility.clone(),
13456        }),
13457        MAX_NODE_HELLO_FRAME_BYTES,
13458    )
13459    .await?;
13460    let authentication = timeout(
13461        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13462        read_json_frame_limited_body_timeout(
13463            &mut pipe,
13464            MAX_NODE_HELLO_FRAME_BYTES,
13465            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13466        ),
13467    )
13468    .await
13469    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13470    let ClientFrame::Authenticate(authentication) = authentication else {
13471        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13472    };
13473    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13474        (Some(offer), Some(selected)) => negotiated_auth_proof(
13475            auth_token,
13476            AuthDirection::Client,
13477            hello.role,
13478            &hello.client_nonce,
13479            &server_nonce,
13480            offer,
13481            selected,
13482        ),
13483        (None, None) => auth_proof(
13484            auth_token,
13485            AuthDirection::Client,
13486            hello.role,
13487            &hello.client_nonce,
13488            &server_nonce,
13489        ),
13490        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13491    }
13492    .map_err(NodeServerError::Authentication)?;
13493    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13494        return Err(NodeServerError::Handshake("access denied".to_owned()));
13495    }
13496    let selected_capabilities = compatibility
13497        .as_ref()
13498        .map(|selected| selected.capabilities.clone())
13499        .unwrap_or_default();
13500    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13501        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13502    });
13503    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13504        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13505    });
13506    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13507        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13508    });
13509    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13510        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13511    });
13512    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13513        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13514    });
13515    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13516        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13517    }) && selected_capabilities.iter().any(|capability| {
13518        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13519    });
13520    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13521        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13522    });
13523    let include_session_bundles = selected_capabilities.iter().any(|capability| {
13524        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13525    });
13526    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13527        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13528    });
13529    let include_agent_progress = selected_capabilities.iter().any(|capability| {
13530        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13531    });
13532    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13533        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13534    });
13535    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13536        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13537    });
13538    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13539        .try_acquire_owned()
13540        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13541    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13542    let _release_guard = ControllerReleaseGuard {
13543        shared: Arc::clone(&shared),
13544        connection_id,
13545    };
13546    drop(preauth_permit);
13547    let _authenticated_permit = authenticated_permit;
13548    let mut event_rx = shared.event_tx.subscribe();
13549    let mut terminal_event_rx = terminal_event_subscription(
13550        &shared,
13551        include_terminal_frame_events,
13552    );
13553    let mut agent_stream_event_rx = agent_stream_event_subscription(
13554        &shared,
13555        include_agent_stream_events,
13556    );
13557    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13558        &shared,
13559        include_harness_mcp_proxy,
13560    );
13561    write_json_frame(
13562        &mut pipe,
13563        &ServerFrame::Hello(NodeHello {
13564            build_stamp: BUILD_STAMP.to_owned(),
13565            incarnation_id: shared.incarnation_id,
13566            connection_id,
13567            role: hello.role,
13568            event_sequence: shared.current_sequence(),
13569            controller: shared.controller_state(),
13570            snapshot: snapshot_for_wire(
13571                &shared,
13572                include_provider_runtime_status,
13573                include_open_provider_ids,
13574                include_spawn_profiles,
13575                include_managed_worktrees,
13576                include_child_environment_profiles,
13577                include_session_bundles,
13578                include_history_context_packs,
13579                include_agent_progress,
13580                include_session_task_correlation,
13581            ),
13582            compatibility,
13583        }),
13584    )
13585    .await?;
13586    let (mut reader, mut writer) = tokio::io::split(pipe);
13587    let (frame_tx, mut frame_rx) = mpsc::channel(16);
13588    let reader_task = tokio::spawn(async move {
13589        loop {
13590            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13591                &mut reader,
13592                MAX_NODE_CLIENT_FRAME_BYTES,
13593                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13594            )
13595            .await;
13596            let terminal = frame.is_err();
13597            if frame_tx.send(frame).await.is_err() || terminal {
13598                break;
13599            }
13600        }
13601    });
13602    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13603    let mut pending_events = Vec::new();
13604    let mut resync_required = false;
13605    let mut discard_events_through = 0_u64;
13606    loop {
13607        // One tick of the serve loop, whether or not it had anything to do.
13608        // Against `connection_events_sent` this separates "busy" from
13609        // "spinning": a loop turning over thousands of times a second while
13610        // sending nothing is not serving, it is burning a core.
13611        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13612        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13613            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13614        for _ in 0..durable_drain_budget {
13615            match event_rx.try_recv() {
13616                Ok(event) => queue_connection_event(
13617                    &mut pending_events,
13618                    event,
13619                    discard_events_through,
13620                ),
13621                Err(broadcast::error::TryRecvError::Empty) => break,
13622                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13623                    resync_required = true;
13624                    break;
13625                }
13626                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13627            }
13628        }
13629        if let Some(receiver) = terminal_event_rx.as_mut() {
13630            match receiver.try_recv() {
13631                Ok(events) => queue_connection_event_batch(
13632                    &mut pending_events,
13633                    &events,
13634                    discard_events_through,
13635                ),
13636                Err(broadcast::error::TryRecvError::Empty) => {}
13637                Err(broadcast::error::TryRecvError::Lagged(_)) => {
13638                    resync_required = true;
13639                }
13640                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13641            }
13642        }
13643        if let Some(receiver) = agent_stream_event_rx.as_mut() {
13644            // Loops up to the same remaining-room budget the durable
13645            // `event_rx` drain above uses, rather than one `try_recv` per
13646            // tick: each successful receive here is itself only ONE
13647            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
13648            // sends `vec![envelope]`, never more), so a single-shot drain
13649            // caps this connection's agent-stream throughput at one chunk
13650            // per outer-loop tick regardless of how many are already queued
13651            // -- exactly the kind of self-inflicted stall this crate's own
13652            // durable-event budget exists to avoid.
13653            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13654                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13655            for _ in 0..agent_stream_drain_budget {
13656                match receiver.try_recv() {
13657                    Ok(events) => queue_connection_event_batch(
13658                        &mut pending_events,
13659                        &events,
13660                        discard_events_through,
13661                    ),
13662                    Err(broadcast::error::TryRecvError::Empty) => break,
13663                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13664                        tracing::warn!(
13665                            connection_id,
13666                            dropped,
13667                            "agent stream event subscription lagged; forcing a resync",
13668                        );
13669                        resync_required = true;
13670                        break;
13671                    }
13672                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13673                }
13674            }
13675        }
13676        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13677            // Mirrors the agent-stream drain immediately above: each
13678            // successful receive is one single-envelope batch
13679            // (`publish_transient` always sends `vec![envelope]`), so this
13680            // loops up to the same remaining-room budget rather than
13681            // stopping after one `try_recv`.
13682            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13683                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13684            for _ in 0..harness_mcp_proxy_drain_budget {
13685                match receiver.try_recv() {
13686                    Ok(events) => queue_connection_event_batch(
13687                        &mut pending_events,
13688                        &events,
13689                        discard_events_through,
13690                    ),
13691                    Err(broadcast::error::TryRecvError::Empty) => break,
13692                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13693                        tracing::warn!(
13694                            connection_id,
13695                            dropped,
13696                            "harness MCP proxy event subscription lagged; forcing a resync",
13697                        );
13698                        resync_required = true;
13699                        break;
13700                    }
13701                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13702                }
13703            }
13704        }
13705        if resync_required {
13706            let event = resync_required_event(&shared);
13707            apply_connection_resync_watermark(
13708                &mut pending_events,
13709                &mut discard_events_through,
13710                event.sequence,
13711            );
13712            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13713            resync_required = false;
13714            continue;
13715        }
13716        if !pending_events.is_empty() {
13717            pending_events.sort_unstable_by_key(|event| event.sequence);
13718            let burst_len = connection_event_burst_len(pending_events.len());
13719            shared
13720                .connection_events_sent
13721                .fetch_add(burst_len as u64, Ordering::Relaxed);
13722            for event in pending_events.drain(..burst_len) {
13723                let event = if include_managed_worktrees {
13724                    Some(event)
13725                } else {
13726                    project_event_without_managed_worktrees(event)
13727                };
13728                let event = event.and_then(|event| if include_open_provider_ids {
13729                    Some(event)
13730                } else {
13731                    project_event_legacy_provider_ids(&shared, event)
13732                });
13733                let event = event.map(|event| if include_child_environment_profiles {
13734                    event
13735                } else {
13736                    project_event_without_child_environment_profile(event)
13737                });
13738                let event = event.map(|event| if include_session_bundles {
13739                    event
13740                } else {
13741                    project_event_without_session_bundle(event)
13742                });
13743                let event = event.map(|event| {
13744                    project_event_history_for_wire(event, include_history_context_packs)
13745                });
13746                let event = event.map(|event| if include_history_context_packs {
13747                    event
13748                } else {
13749                    project_event_without_context_pack(event)
13750                });
13751                let event = event.map(|event| if include_session_task_correlation {
13752                    event
13753                } else {
13754                    project_event_without_session_task_binding(event)
13755                });
13756                let exact_harness_controller = hello.role == ClientRole::Operator
13757                    && shared.controller_state().is_some_and(|controller| {
13758                        controller.connection_id == connection_id
13759                    });
13760                let event = event.and_then(|event| {
13761                    if include_harness_mcp_proxy && exact_harness_controller {
13762                        Some(event)
13763                    } else {
13764                        project_event_without_harness_mcp_proxy(event)
13765                    }
13766                });
13767                if let Some(event) = event {
13768                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13769                }
13770            }
13771        }
13772        tokio::select! {
13773            biased;
13774            frame = frame_rx.recv() => {
13775                let frame = match frame {
13776                    Some(frame) => frame,
13777                    None => break,
13778                };
13779                let frame = match frame {
13780                    Ok(frame) => frame,
13781                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13782                    Err(error) => return Err(error.into()),
13783                };
13784                let ClientFrame::Request(request) = frame else {
13785                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13786                };
13787                #[cfg(feature = "fixture")]
13788                let probe_spawn_managed_worktree_v2 = matches!(
13789                    &request.request,
13790                    NodeRequest::SpawnManagedWorktreeV2 { .. }
13791                );
13792                let requires_open_provider_ids =
13793                    request_requires_open_provider_ids(&shared, &request.request);
13794                let requires_child_environment_profile =
13795                    request_requires_child_environment_profile(&shared, &request.request);
13796                let requires_session_bundle =
13797                    request_requires_session_bundle(&shared, &request.request);
13798                let requires_history_context_pack =
13799                    request_requires_history_context_pack(&shared, &request.request);
13800                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13801                    ResponseEnvelope {
13802                        request_id: request.request_id,
13803                        result: Err(failure(
13804                            NodeFailureCode::UnsupportedCapability,
13805                            "open provider IDs were not negotiated",
13806                        )),
13807                    }
13808                } else if requires_child_environment_profile
13809                    && !include_child_environment_profiles
13810                {
13811                    ResponseEnvelope {
13812                        request_id: request.request_id,
13813                        result: Err(failure(
13814                            NodeFailureCode::UnsupportedCapability,
13815                            "child environment profiles were not negotiated",
13816                        )),
13817                    }
13818                } else if requires_session_bundle && !include_session_bundles {
13819                    ResponseEnvelope {
13820                        request_id: request.request_id,
13821                        result: Err(failure(
13822                            NodeFailureCode::UnsupportedCapability,
13823                            "session bundles were not negotiated",
13824                        )),
13825                    }
13826                } else if requires_history_context_pack && !include_history_context_packs {
13827                    ResponseEnvelope {
13828                        request_id: request.request_id,
13829                        result: Err(failure(
13830                            NodeFailureCode::UnsupportedCapability,
13831                            "history context packs were not negotiated",
13832                        )),
13833                    }
13834                } else if request_uses_unnegotiated_capability(
13835                    &request.request,
13836                    &selected_capabilities,
13837                )
13838                {
13839                    ResponseEnvelope {
13840                        request_id: request.request_id,
13841                        result: Err(failure(
13842                            NodeFailureCode::UnsupportedCapability,
13843                            "request capability was not negotiated",
13844                        )),
13845                    }
13846                } else {
13847                    process_request(&shared, connection_id, hello.role, request).await
13848                };
13849                #[cfg(feature = "fixture")]
13850                if probe_spawn_managed_worktree_v2 {
13851                    shared
13852                        .fixture_spawn_managed_worktree_v2_failure_probe
13853                        .record(reply.result.as_ref().err().map(|failure| failure.code));
13854                }
13855                if !include_provider_runtime_status {
13856                    clear_response_provider_runtime_status(&mut reply);
13857                }
13858                if !include_managed_worktrees {
13859                    project_response_without_managed_worktrees(&mut reply);
13860                }
13861                if !include_open_provider_ids {
13862                    project_response_legacy_provider_ids(&shared, &mut reply);
13863                }
13864                if !include_child_environment_profiles {
13865                    project_response_without_child_environment_profile(&mut reply);
13866                }
13867                if !include_session_bundles {
13868                    project_response_without_session_bundle(&mut reply);
13869                }
13870                project_response_history_for_wire(
13871                    &mut reply,
13872                    include_history_context_packs,
13873                );
13874                if !include_history_context_packs {
13875                    project_response_without_context_pack(&mut reply);
13876                }
13877                if !include_agent_progress {
13878                    project_response_without_agent_progress(&mut reply);
13879                }
13880                if !include_session_task_correlation {
13881                    project_response_without_session_task_binding(&mut reply);
13882                }
13883                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13884            }
13885            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13886            event = event_rx.recv() => {
13887                match event {
13888                    Ok(event) => queue_connection_event(
13889                        &mut pending_events,
13890                        event,
13891                        discard_events_through,
13892                    ),
13893                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13894                    Err(broadcast::error::RecvError::Closed) => break,
13895                }
13896            }
13897            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13898                match events {
13899                    Ok(events) => queue_connection_event_batch(
13900                        &mut pending_events,
13901                        &events,
13902                        discard_events_through,
13903                    ),
13904                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13905                    Err(broadcast::error::RecvError::Closed) => break,
13906                }
13907            }
13908            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13909                match events {
13910                    Ok(events) => queue_connection_event_batch(
13911                        &mut pending_events,
13912                        &events,
13913                        discard_events_through,
13914                    ),
13915                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13916                    Err(broadcast::error::RecvError::Closed) => break,
13917                }
13918            }
13919            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13920                match events {
13921                    Ok(events) => queue_connection_event_batch(
13922                        &mut pending_events,
13923                        &events,
13924                        discard_events_through,
13925                    ),
13926                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13927                    Err(broadcast::error::RecvError::Closed) => break,
13928                }
13929            }
13930            _ = shared.shutdown_notify.notified() => break,
13931        }
13932    }
13933    Ok(())
13934}
13935
13936async fn receive_terminal_event_batch(
13937    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13938) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13939    match receiver {
13940        Some(receiver) => receiver.recv().await,
13941        None => std::future::pending().await,
13942    }
13943}
13944
13945fn terminal_event_subscription(
13946    shared: &NodeShared,
13947    enabled: bool,
13948) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13949    enabled.then(|| shared.terminal_event_tx.subscribe())
13950}
13951
13952/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
13953/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
13954/// this needs a dedicated channel rather than a flag on the terminal one.
13955fn agent_stream_event_subscription(
13956    shared: &NodeShared,
13957    enabled: bool,
13958) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13959    enabled.then(|| shared.agent_stream_event_tx.subscribe())
13960}
13961
13962/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
13963/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
13964/// own doc for why this needs a dedicated channel rather than a ride on
13965/// the durable `event_tx`.
13966fn harness_mcp_proxy_event_subscription(
13967    shared: &NodeShared,
13968    enabled: bool,
13969) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13970    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13971}
13972
13973fn queue_connection_event(
13974    pending: &mut Vec<NodeEventEnvelope>,
13975    event: NodeEventEnvelope,
13976    discard_through: u64,
13977) {
13978    if event.sequence <= discard_through
13979        && !event.event.requires_harness_mcp_proxy_capability()
13980    {
13981        return;
13982    }
13983    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13984        if let Some(index) = pending.iter().position(|current| {
13985            matches!(
13986                &current.event,
13987                NodeEvent::TerminalFrame {
13988                    address: current_address,
13989                    ..
13990                } if current_address == address
13991            )
13992        }) {
13993            pending.remove(index);
13994        }
13995    }
13996    pending.push(event);
13997}
13998
13999fn project_event_without_harness_mcp_proxy(
14000    envelope: NodeEventEnvelope,
14001) -> Option<NodeEventEnvelope> {
14002    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14003}
14004
14005fn queue_connection_event_batch(
14006    pending: &mut Vec<NodeEventEnvelope>,
14007    events: &[NodeEventEnvelope],
14008    discard_through: u64,
14009) {
14010    for event in events {
14011        queue_connection_event(pending, event.clone(), discard_through);
14012    }
14013}
14014
14015fn apply_connection_resync_watermark(
14016    pending: &mut Vec<NodeEventEnvelope>,
14017    discard_through: &mut u64,
14018    marker_sequence: u64,
14019) {
14020    *discard_through = (*discard_through).max(marker_sequence);
14021    pending.retain(|event| event.sequence > *discard_through);
14022}
14023
14024fn connection_event_burst_len(pending_len: usize) -> usize {
14025    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14026}
14027
14028fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14029    let history = shared
14030        .history
14031        .lock()
14032        .unwrap_or_else(|poisoned| poisoned.into_inner());
14033    let sequence = history.last_sequence;
14034    let oldest_available_sequence = history.replay_floor_sequence;
14035    NodeEventEnvelope {
14036        sequence,
14037        event: NodeEvent::ResyncRequired {
14038            oldest_available_sequence,
14039        },
14040    }
14041}
14042
14043fn node_compatibility_support(
14044    shared: &NodeShared,
14045) -> Result<NodeCompatibilitySupport, NodeServerError> {
14046    let mut support = node_compatibility_support_for_manifest(
14047        &shared.provider_contracts,
14048        &shared.provider_adapter_contracts,
14049    )?;
14050    if shared
14051        .delivery_store
14052        .lock()
14053        .unwrap_or_else(|poisoned| poisoned.into_inner())
14054        .is_some()
14055    {
14056        support.capabilities.push(
14057            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14058                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14059        );
14060    }
14061    if shared.harness_mcp_registry.is_some() {
14062        support.capabilities.push(
14063            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14064                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14065        );
14066    }
14067    Ok(support)
14068}
14069
14070fn node_compatibility_support_for_manifest(
14071    provider_contracts: &[ProviderContractSupport],
14072    provider_adapter_contracts: &[ProviderAdapterContractSupport],
14073) -> Result<NodeCompatibilitySupport, NodeServerError> {
14074    Ok(NodeCompatibilitySupport {
14075        build_stamp: BUILD_STAMP.to_owned(),
14076        capabilities: baseline_capabilities()?,
14077        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14078        path_semantics: platform::path_semantics(),
14079        local_transport: platform::local_transport(),
14080        state_schema: StateSchemaSupport {
14081            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14082                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14083        },
14084        provider_contracts: provider_contracts.to_vec(),
14085        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14086    })
14087}
14088
14089fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14090    let capabilities = [
14091        NODE_COMPATIBILITY_METADATA_CAPABILITY,
14092        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14093        NODE_REPOSITORY_PATH_CAPABILITY,
14094        NODE_WORKSPACE_FILE_READ_CAPABILITY,
14095        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14096        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14097        NODE_GIT_READ_CAPABILITY,
14098        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14099        NODE_PROVIDER_ID_OPEN_CAPABILITY,
14100        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14101        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14102        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14103        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14104        NODE_ACP_CONTROL_CAPABILITY,
14105        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14106        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14107        NODE_WORKTREE_SELECTION_CAPABILITY,
14108        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14109        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14110        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14111        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14112        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14113        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14114        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14115        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14116        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14117        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14118        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14119        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14120    ]
14121        .into_iter()
14122        .map(|capability| {
14123            CapabilityId::new(capability)
14124                .map_err(|error| NodeServerError::Handshake(error.to_string()))
14125        })
14126        .collect::<Result<Vec<_>, _>>()?;
14127    #[cfg(windows)]
14128    let capabilities = {
14129        let mut capabilities = capabilities;
14130        capabilities.push(
14131            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14132                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14133        );
14134        capabilities
14135    };
14136    Ok(capabilities)
14137}
14138
14139fn request_uses_unnegotiated_capability(
14140    request: &NodeRequest,
14141    selected_capabilities: &[CapabilityId],
14142) -> bool {
14143    let selected = |required: &str| {
14144        selected_capabilities
14145            .iter()
14146            .any(|capability| capability.as_str() == required)
14147    };
14148    request
14149        .required_capability()
14150        .is_some_and(|required| !selected(required))
14151        || (request.requires_worktree_selection_capability()
14152            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14153        || (request.requires_spawn_spec_defaults_overrides_capability()
14154            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14155        || (request.requires_spawn_profile_revision_capability()
14156            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14157        || (request.requires_child_environment_profile_capability()
14158            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14159        || (request.requires_session_bundle_materialization_capability()
14160            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14161        || (request.requires_history_context_pack_capability()
14162            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14163}
14164
14165fn snapshot_for_wire(
14166    shared: &NodeShared,
14167    include_provider_runtime_status: bool,
14168    include_open_provider_ids: bool,
14169    include_spawn_profiles: bool,
14170    include_managed_worktrees: bool,
14171    include_child_environment_profiles: bool,
14172    include_session_bundles: bool,
14173    include_history_context_packs: bool,
14174    include_agent_progress: bool,
14175    include_session_task_correlation: bool,
14176) -> NodeSnapshot {
14177    let mut snapshot = shared.snapshot();
14178    if !include_provider_runtime_status {
14179        snapshot.provider_runtime_statuses.clear();
14180    }
14181    if !include_open_provider_ids {
14182        project_snapshot_legacy_provider_ids(&mut snapshot);
14183    }
14184    if !include_managed_worktrees {
14185        snapshot.managed_worktrees.clear();
14186        for workspace in &mut snapshot.workspaces {
14187            workspace.managed_worktree_profiles = None;
14188        }
14189    }
14190    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14191        if !include_spawn_profiles {
14192            inventory.spawn_profiles = None;
14193        }
14194        if !include_session_bundles {
14195            inventory.bundles = None;
14196        }
14197        if inventory.spawn_profiles.is_none()
14198            && inventory.bundles.is_none()
14199            && inventory.network_allowlists.is_none()
14200        {
14201            snapshot.launch_inventory = None;
14202        }
14203    }
14204    if !include_child_environment_profiles {
14205        clear_snapshot_child_environment_profiles(&mut snapshot);
14206    }
14207    if !include_session_bundles {
14208        clear_snapshot_session_bundles(&mut snapshot);
14209    }
14210    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14211    if !include_history_context_packs {
14212        clear_snapshot_context_packs(&mut snapshot);
14213    }
14214    if !include_agent_progress {
14215        snapshot.agent_progress.clear();
14216    }
14217    if !include_session_task_correlation {
14218        clear_snapshot_session_task_bindings(&mut snapshot);
14219    }
14220    snapshot
14221}
14222
14223fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14224    for record in &mut snapshot.session_records {
14225        record.task_binding = None;
14226    }
14227}
14228
14229fn project_event_without_session_task_binding(
14230    mut envelope: NodeEventEnvelope,
14231) -> NodeEventEnvelope {
14232    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14233        record.task_binding = None;
14234    }
14235    envelope
14236}
14237
14238fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14239    let Ok(response) = reply.result.as_mut() else { return };
14240    match response {
14241        NodeResponse::Snapshot { snapshot, .. } => {
14242            clear_snapshot_session_task_bindings(snapshot);
14243        }
14244        NodeResponse::Resync { snapshot, events, .. } => {
14245            clear_snapshot_session_task_bindings(snapshot);
14246            for event in events {
14247                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14248                    record.task_binding = None;
14249                }
14250            }
14251        }
14252        NodeResponse::SessionRecordUpdated { record }
14253        | NodeResponse::ProviderSessionIndexed { record }
14254        | NodeResponse::NativeSessionIndexed { record, .. }
14255        | NodeResponse::SessionRecordResumed { record, .. } => {
14256            record.task_binding = None;
14257        }
14258        _ => {}
14259    }
14260}
14261
14262fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14263    let Ok(response) = reply.result.as_mut() else { return };
14264    match response {
14265        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14266            snapshot.agent_progress.clear();
14267        }
14268        _ => {}
14269    }
14270}
14271
14272fn project_event_without_managed_worktrees(
14273    mut envelope: NodeEventEnvelope,
14274) -> Option<NodeEventEnvelope> {
14275    if matches!(
14276        envelope.event,
14277        NodeEvent::ManagedWorktreeUpserted { .. }
14278            | NodeEvent::ManagedWorktreeRemoved { .. }
14279    ) {
14280        return None;
14281    }
14282    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14283        workspace.managed_worktree_profiles = None;
14284    }
14285    Some(envelope)
14286}
14287
14288fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14289    if matches!(
14290        reply.result,
14291        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14292            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14293    ) {
14294        reply.result = Err(failure(
14295            NodeFailureCode::UnsupportedCapability,
14296            "managed worktree capability was not negotiated",
14297        ));
14298        return;
14299    }
14300    let Ok(response) = reply.result.as_mut() else { return };
14301    match response {
14302        NodeResponse::Snapshot { snapshot, .. } => {
14303            snapshot.managed_worktrees.clear();
14304            for workspace in &mut snapshot.workspaces {
14305                workspace.managed_worktree_profiles = None;
14306            }
14307        }
14308        NodeResponse::Resync { snapshot, events, .. } => {
14309            snapshot.managed_worktrees.clear();
14310            for workspace in &mut snapshot.workspaces {
14311                workspace.managed_worktree_profiles = None;
14312            }
14313            *events = std::mem::take(events)
14314                .into_iter()
14315                .filter_map(project_event_without_managed_worktrees)
14316                .collect();
14317        }
14318        NodeResponse::WorkspaceRegistered { workspace }
14319        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14320        | NodeResponse::WorktreeCreated { workspace, .. } => {
14321            workspace.managed_worktree_profiles = None;
14322        }
14323        NodeResponse::WorkspaceInspected { inspection } => {
14324            inspection.git.managed_worktree = None;
14325        }
14326        _ => {}
14327    }
14328}
14329
14330fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14331    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14332        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14333    });
14334    if includes_open_provider_ids {
14335        return;
14336    }
14337    compatibility
14338        .provider_contracts
14339        .retain(|contract| provider_id_is_legacy(&contract.provider));
14340    compatibility
14341        .provider_adapter_contracts
14342        .retain(|contract| provider_id_is_legacy(&contract.provider));
14343}
14344
14345fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14346    if let Some(spec) = match request {
14347        NodeRequest::SpawnSpec { spec }
14348        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14349        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14350        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14351        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14352        _ => None,
14353    } {
14354        return shared
14355            .resolve_spawn_spec(spec)
14356            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14357    }
14358    request_requires_open_provider_ids_with(
14359        request,
14360        |session| shared.validate_address(session).ok(),
14361        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14362    )
14363}
14364
14365fn request_requires_child_environment_profile(
14366    shared: &NodeShared,
14367    request: &NodeRequest,
14368) -> bool {
14369    if let Some(spec) = match request {
14370        NodeRequest::SpawnSpec { spec }
14371        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14372        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14373        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14374        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14375        _ => None,
14376    } {
14377        return shared
14378            .resolve_spawn_spec(spec)
14379            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14380    }
14381    match request {
14382        NodeRequest::Resume { session, .. }
14383        | NodeRequest::Prompt { session, .. }
14384        | NodeRequest::Paste { session, .. }
14385        | NodeRequest::Input { session, .. }
14386        | NodeRequest::TerminalBytes { session, .. }
14387        | NodeRequest::TerminalControl { session, .. }
14388        | NodeRequest::Resize { session, .. }
14389        | NodeRequest::Interrupt { session }
14390        | NodeRequest::Stop { session, .. }
14391        | NodeRequest::Remove { session }
14392        | NodeRequest::ResolveInteraction { session, .. }
14393        | NodeRequest::SetSessionMode { session, .. }
14394        | NodeRequest::SetSessionConfigOption { session, .. }
14395        | NodeRequest::SetSessionModel { session, .. }
14396        | NodeRequest::DiscoverHistory { session, .. }
14397        | NodeRequest::LoadHistory { session, .. }
14398        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14399        | NodeRequest::ExportContextPack { session } => shared
14400            .session_bindings
14401            .lock()
14402            .unwrap_or_else(|poisoned| poisoned.into_inner())
14403            .get(&session.session.instance_id)
14404            .filter(|binding| {
14405                binding.workspace_id == session.workspace_id
14406                    && binding.generation == session.session.generation
14407            })
14408            .map_or(true, |binding| binding.environment_profile.is_some()),
14409        NodeRequest::RenameSessionRecord { record_id, .. }
14410        | NodeRequest::SetSessionTask { record_id, .. }
14411        | NodeRequest::ResumeSessionRecord { record_id, .. }
14412        | NodeRequest::ForgetSessionRecord { record_id } => shared
14413            .record(record_id)
14414            .map_or(true, |record| record.environment_profile.is_some()),
14415        NodeRequest::ArmHarnessMcpReservation { .. }
14416        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14417        | NodeRequest::ActivateHarnessMcpReservation { .. }
14418        | NodeRequest::AbortHarnessMcpReservation { .. }
14419        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14420        | NodeRequest::RejectHarnessMcpCall { .. }
14421        | NodeRequest::Snapshot
14422        | NodeRequest::Resync { .. }
14423        | NodeRequest::BeginDeliveryStage { .. }
14424        | NodeRequest::PutDeliveryBlobChunk { .. }
14425        | NodeRequest::CommitDeliveryStage { .. }
14426        | NodeRequest::AbortDeliveryStage { .. }
14427        | NodeRequest::BrowseHostDirectories { .. }
14428        | NodeRequest::InspectWorkspace { .. }
14429        | NodeRequest::ReadWorkspaceFile { .. }
14430        | NodeRequest::WriteWorkspaceFile { .. }
14431        | NodeRequest::CreateWorkspaceFile { .. }
14432        | NodeRequest::CreateWorkspaceDirectory { .. }
14433        | NodeRequest::ReadGitHistory { .. }
14434        | NodeRequest::ReadGitDiff { .. }
14435        | NodeRequest::AcquireController { .. }
14436        | NodeRequest::ReleaseController
14437        | NodeRequest::Spawn { .. }
14438        | NodeRequest::SpawnWithOptions { .. }
14439        | NodeRequest::SpawnSpec { .. }
14440        | NodeRequest::RegisterWorkspace { .. }
14441        | NodeRequest::CreateStandaloneWorkspace { .. }
14442        | NodeRequest::UnregisterWorkspace { .. }
14443        | NodeRequest::CreateWorktree { .. }
14444        | NodeRequest::RemoveWorktree { .. }
14445        | NodeRequest::SpawnManagedWorktree { .. }
14446        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14447        | NodeRequest::CleanupManagedWorktree { .. }
14448        | NodeRequest::IndexProviderSession { .. }
14449        | NodeRequest::IndexNativeSession { .. }
14450        | NodeRequest::CatalogNativeSessions { .. }
14451        | NodeRequest::PageNativeSessions { .. }
14452        | NodeRequest::PreviewNativeSession { .. }
14453        | NodeRequest::PreviewSessionRecord { .. }
14454        | NodeRequest::ForgetContextPack { .. }
14455        | NodeRequest::ResolveDurableContextPack { .. }
14456        | NodeRequest::ReadContextPack { .. }
14457        | NodeRequest::Shutdown => false,
14458    }
14459}
14460
14461fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14462    if let Some(spec) = match request {
14463        NodeRequest::SpawnSpec { spec }
14464        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14465        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14466        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14467        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14468        _ => None,
14469    } {
14470        return shared
14471            .resolve_spawn_spec(spec)
14472            .is_ok_and(|resolved| resolved.bundle_id.is_some());
14473    }
14474    match request {
14475        NodeRequest::Resume { session, .. }
14476        | NodeRequest::Prompt { session, .. }
14477        | NodeRequest::Paste { session, .. }
14478        | NodeRequest::Input { session, .. }
14479        | NodeRequest::TerminalBytes { session, .. }
14480        | NodeRequest::TerminalControl { session, .. }
14481        | NodeRequest::Resize { session, .. }
14482        | NodeRequest::Interrupt { session }
14483        | NodeRequest::Stop { session, .. }
14484        | NodeRequest::Remove { session }
14485        | NodeRequest::ResolveInteraction { session, .. }
14486        | NodeRequest::SetSessionMode { session, .. }
14487        | NodeRequest::SetSessionConfigOption { session, .. }
14488        | NodeRequest::SetSessionModel { session, .. }
14489        | NodeRequest::DiscoverHistory { session, .. }
14490        | NodeRequest::LoadHistory { session, .. }
14491        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14492        | NodeRequest::ExportContextPack { session } => shared
14493            .session_bindings
14494            .lock()
14495            .unwrap_or_else(|poisoned| poisoned.into_inner())
14496            .get(&session.session.instance_id)
14497            .filter(|binding| {
14498                binding.workspace_id == session.workspace_id
14499                    && binding.generation == session.session.generation
14500            })
14501            .map_or(true, |binding| binding.bundle.is_some()),
14502        NodeRequest::RenameSessionRecord { record_id, .. }
14503        | NodeRequest::SetSessionTask { record_id, .. }
14504        | NodeRequest::ResumeSessionRecord { record_id, .. }
14505        | NodeRequest::ForgetSessionRecord { record_id } => shared
14506            .record(record_id)
14507            .map_or(true, |record| record.bundle.is_some()),
14508        NodeRequest::ArmHarnessMcpReservation { .. }
14509        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14510        | NodeRequest::ActivateHarnessMcpReservation { .. }
14511        | NodeRequest::AbortHarnessMcpReservation { .. }
14512        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14513        | NodeRequest::RejectHarnessMcpCall { .. }
14514        | NodeRequest::Snapshot
14515        | NodeRequest::Resync { .. }
14516        | NodeRequest::BeginDeliveryStage { .. }
14517        | NodeRequest::PutDeliveryBlobChunk { .. }
14518        | NodeRequest::CommitDeliveryStage { .. }
14519        | NodeRequest::AbortDeliveryStage { .. }
14520        | NodeRequest::BrowseHostDirectories { .. }
14521        | NodeRequest::InspectWorkspace { .. }
14522        | NodeRequest::ReadWorkspaceFile { .. }
14523        | NodeRequest::WriteWorkspaceFile { .. }
14524        | NodeRequest::CreateWorkspaceFile { .. }
14525        | NodeRequest::CreateWorkspaceDirectory { .. }
14526        | NodeRequest::ReadGitHistory { .. }
14527        | NodeRequest::ReadGitDiff { .. }
14528        | NodeRequest::AcquireController { .. }
14529        | NodeRequest::ReleaseController
14530        | NodeRequest::Spawn { .. }
14531        | NodeRequest::SpawnWithOptions { .. }
14532        | NodeRequest::SpawnSpec { .. }
14533        | NodeRequest::RegisterWorkspace { .. }
14534        | NodeRequest::CreateStandaloneWorkspace { .. }
14535        | NodeRequest::UnregisterWorkspace { .. }
14536        | NodeRequest::CreateWorktree { .. }
14537        | NodeRequest::RemoveWorktree { .. }
14538        | NodeRequest::SpawnManagedWorktree { .. }
14539        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14540        | NodeRequest::CleanupManagedWorktree { .. }
14541        | NodeRequest::IndexProviderSession { .. }
14542        | NodeRequest::IndexNativeSession { .. }
14543        | NodeRequest::CatalogNativeSessions { .. }
14544        | NodeRequest::PageNativeSessions { .. }
14545        | NodeRequest::PreviewNativeSession { .. }
14546        | NodeRequest::PreviewSessionRecord { .. }
14547        | NodeRequest::ForgetContextPack { .. }
14548        | NodeRequest::ResolveDurableContextPack { .. }
14549        | NodeRequest::ReadContextPack { .. }
14550        | NodeRequest::Shutdown => false,
14551    }
14552}
14553
14554fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14555    if request.requires_history_context_pack_capability() {
14556        return true;
14557    }
14558    if let Some(spec) = match request {
14559        NodeRequest::SpawnSpec { spec }
14560        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14561        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14562        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14563        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14564        _ => None,
14565    } {
14566        return shared
14567            .resolve_spawn_spec(spec)
14568            .map_or(true, |resolved| resolved.context_id.is_some());
14569    }
14570    match request {
14571        NodeRequest::Resume { session, .. }
14572        | NodeRequest::Prompt { session, .. }
14573        | NodeRequest::Paste { session, .. }
14574        | NodeRequest::Input { session, .. }
14575        | NodeRequest::TerminalBytes { session, .. }
14576        | NodeRequest::TerminalControl { session, .. }
14577        | NodeRequest::Resize { session, .. }
14578        | NodeRequest::Interrupt { session }
14579        | NodeRequest::Stop { session, .. }
14580        | NodeRequest::Remove { session }
14581        | NodeRequest::ResolveInteraction { session, .. }
14582        | NodeRequest::SetSessionMode { session, .. }
14583        | NodeRequest::SetSessionConfigOption { session, .. }
14584        | NodeRequest::SetSessionModel { session, .. } => shared
14585            .session_bindings
14586            .lock()
14587            .unwrap_or_else(|poisoned| poisoned.into_inner())
14588            .get(&session.session.instance_id)
14589            .filter(|binding| {
14590                binding.workspace_id == session.workspace_id
14591                    && binding.generation == session.session.generation
14592            })
14593            .map_or(true, |binding| binding.context.is_some()),
14594        NodeRequest::RenameSessionRecord { record_id, .. }
14595        | NodeRequest::SetSessionTask { record_id, .. }
14596        | NodeRequest::ResumeSessionRecord { record_id, .. }
14597        | NodeRequest::ForgetSessionRecord { record_id } => shared
14598            .record(record_id)
14599            .map_or(true, |record| record.context.is_some()),
14600        NodeRequest::ArmHarnessMcpReservation { .. }
14601        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14602        | NodeRequest::ActivateHarnessMcpReservation { .. }
14603        | NodeRequest::AbortHarnessMcpReservation { .. }
14604        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14605        | NodeRequest::RejectHarnessMcpCall { .. }
14606        | NodeRequest::Snapshot
14607        | NodeRequest::Resync { .. }
14608        | NodeRequest::BeginDeliveryStage { .. }
14609        | NodeRequest::PutDeliveryBlobChunk { .. }
14610        | NodeRequest::CommitDeliveryStage { .. }
14611        | NodeRequest::AbortDeliveryStage { .. }
14612        | NodeRequest::BrowseHostDirectories { .. }
14613        | NodeRequest::InspectWorkspace { .. }
14614        | NodeRequest::ReadWorkspaceFile { .. }
14615        | NodeRequest::WriteWorkspaceFile { .. }
14616        | NodeRequest::CreateWorkspaceFile { .. }
14617        | NodeRequest::CreateWorkspaceDirectory { .. }
14618        | NodeRequest::ReadGitHistory { .. }
14619        | NodeRequest::ReadGitDiff { .. }
14620        | NodeRequest::AcquireController { .. }
14621        | NodeRequest::ReleaseController
14622        | NodeRequest::Spawn { .. }
14623        | NodeRequest::SpawnWithOptions { .. }
14624        | NodeRequest::SpawnSpec { .. }
14625        | NodeRequest::RegisterWorkspace { .. }
14626        | NodeRequest::CreateStandaloneWorkspace { .. }
14627        | NodeRequest::UnregisterWorkspace { .. }
14628        | NodeRequest::CreateWorktree { .. }
14629        | NodeRequest::RemoveWorktree { .. }
14630        | NodeRequest::SpawnManagedWorktree { .. }
14631        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14632        | NodeRequest::CleanupManagedWorktree { .. }
14633        | NodeRequest::IndexProviderSession { .. }
14634        | NodeRequest::IndexNativeSession { .. }
14635        | NodeRequest::CatalogNativeSessions { .. }
14636        | NodeRequest::PageNativeSessions { .. }
14637        | NodeRequest::PreviewNativeSession { .. }
14638        | NodeRequest::PreviewSessionRecord { .. }
14639        | NodeRequest::DiscoverHistory { .. }
14640        | NodeRequest::LoadHistory { .. }
14641        | NodeRequest::ExportContextPackForSessionRecord { .. }
14642        | NodeRequest::ExportContextPack { .. }
14643        | NodeRequest::ForgetContextPack { .. }
14644        | NodeRequest::ResolveDurableContextPack { .. }
14645        | NodeRequest::ReadContextPack { .. }
14646        | NodeRequest::Shutdown => false,
14647    }
14648}
14649
14650fn request_requires_open_provider_ids_with(
14651    request: &NodeRequest,
14652    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14653    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14654) -> bool {
14655    match request {
14656        NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
14657        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14658        NodeRequest::CatalogNativeSessions { route, .. }
14659        | NodeRequest::PageNativeSessions { route, .. } => {
14660            !provider_id_is_legacy(&route.provider)
14661        }
14662        NodeRequest::PreviewNativeSession { selection, .. }
14663        | NodeRequest::IndexNativeSession { selection, .. } => {
14664            !provider_id_is_legacy(&selection.route.provider)
14665        }
14666        NodeRequest::SpawnSpec { .. }
14667        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14668        NodeRequest::SpawnManagedWorktree { .. }
14669        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14670        NodeRequest::Resume { session, .. }
14671        | NodeRequest::Prompt { session, .. }
14672        | NodeRequest::Paste { session, .. }
14673        | NodeRequest::Input { session, .. }
14674        | NodeRequest::TerminalBytes { session, .. }
14675        | NodeRequest::TerminalControl { session, .. }
14676        | NodeRequest::Resize { session, .. }
14677        | NodeRequest::Interrupt { session }
14678        | NodeRequest::Stop { session, .. }
14679        | NodeRequest::Remove { session }
14680        | NodeRequest::ResolveInteraction { session, .. }
14681        | NodeRequest::SetSessionMode { session, .. }
14682        | NodeRequest::SetSessionConfigOption { session, .. }
14683        | NodeRequest::SetSessionModel { session, .. }
14684        | NodeRequest::DiscoverHistory { session, .. }
14685        | NodeRequest::LoadHistory { session, .. }
14686        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14687        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14688            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14689        NodeRequest::RenameSessionRecord { record_id, .. }
14690        | NodeRequest::SetSessionTask { record_id, .. }
14691        | NodeRequest::ResumeSessionRecord { record_id, .. }
14692        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14693            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14694        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14695            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14696        NodeRequest::ArmHarnessMcpReservation { .. }
14697        | NodeRequest::ActivateHarnessMcpReservation { .. }
14698        | NodeRequest::AbortHarnessMcpReservation { .. }
14699        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14700        | NodeRequest::RejectHarnessMcpCall { .. }
14701        | NodeRequest::Snapshot
14702        | NodeRequest::Resync { .. }
14703        | NodeRequest::BeginDeliveryStage { .. }
14704        | NodeRequest::PutDeliveryBlobChunk { .. }
14705        | NodeRequest::CommitDeliveryStage { .. }
14706        | NodeRequest::AbortDeliveryStage { .. }
14707        | NodeRequest::BrowseHostDirectories { .. }
14708        | NodeRequest::InspectWorkspace { .. }
14709        | NodeRequest::ReadWorkspaceFile { .. }
14710        | NodeRequest::WriteWorkspaceFile { .. }
14711        | NodeRequest::CreateWorkspaceFile { .. }
14712        | NodeRequest::CreateWorkspaceDirectory { .. }
14713        | NodeRequest::ReadGitHistory { .. }
14714        | NodeRequest::ReadGitDiff { .. }
14715        | NodeRequest::AcquireController { .. }
14716        | NodeRequest::ReleaseController
14717        | NodeRequest::RegisterWorkspace { .. }
14718        | NodeRequest::CreateStandaloneWorkspace { .. }
14719        | NodeRequest::UnregisterWorkspace { .. }
14720        | NodeRequest::CreateWorktree { .. }
14721        | NodeRequest::RemoveWorktree { .. }
14722        | NodeRequest::CleanupManagedWorktree { .. }
14723        | NodeRequest::Shutdown => false,
14724        NodeRequest::ForgetContextPack { .. } => true,
14725        // No session/record to cheaply resolve a provider from here either;
14726        // the resolved receipt can carry an open-id provider, so require the
14727        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
14728        // is the same digest-keyed lookup one step further into the same
14729        // catalog, so it inherits the same conservative answer.
14730        NodeRequest::ResolveDurableContextPack { .. }
14731        | NodeRequest::ReadContextPack { .. } => true,
14732    }
14733}
14734
14735fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14736    snapshot.enabled_providers.retain(provider_id_is_legacy);
14737    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14738        snapshot
14739            .provider_runtime_statuses
14740            .as_slice()
14741            .iter()
14742            .filter(|status| provider_id_is_legacy(status.provider()))
14743            .cloned(),
14744    )
14745    .expect("a filtered provider runtime projection remains valid");
14746    snapshot
14747        .session_records
14748        .retain(|record| provider_id_is_legacy(&record.provider));
14749    for workspace in &mut snapshot.workspaces {
14750        workspace
14751            .sessions
14752            .retain(|session| provider_id_is_legacy(&session.agent_id));
14753    }
14754}
14755
14756fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14757    for record in &mut snapshot.session_records {
14758        record.environment_profile = None;
14759    }
14760    if let Some(profiles) = snapshot
14761        .launch_inventory
14762        .as_mut()
14763        .and_then(|inventory| inventory.spawn_profiles.as_mut())
14764    {
14765        for profile in profiles {
14766            profile.environment_profile = None;
14767        }
14768    }
14769}
14770
14771fn project_event_without_child_environment_profile(
14772    mut envelope: NodeEventEnvelope,
14773) -> NodeEventEnvelope {
14774    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14775        record.environment_profile = None;
14776    }
14777    envelope
14778}
14779
14780fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14781    let contains_environment_profile = match reply.result.as_ref() {
14782        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14783            receipt.environment_profile.is_some()
14784        }
14785        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14786            receipt.spawn.environment_profile.is_some()
14787        }
14788        _ => false,
14789    };
14790    if contains_environment_profile {
14791        reply.result = Err(failure(
14792            NodeFailureCode::UnsupportedCapability,
14793            "child environment profiles were not negotiated",
14794        ));
14795        return;
14796    }
14797    let Ok(response) = reply.result.as_mut() else {
14798        return;
14799    };
14800    match response {
14801        NodeResponse::Snapshot { snapshot, .. } => {
14802            clear_snapshot_child_environment_profiles(snapshot);
14803        }
14804        NodeResponse::Resync { snapshot, events, .. } => {
14805            clear_snapshot_child_environment_profiles(snapshot);
14806            for event in events {
14807                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14808                    record.environment_profile = None;
14809                }
14810            }
14811        }
14812        NodeResponse::SessionRecordUpdated { record }
14813        | NodeResponse::ProviderSessionIndexed { record }
14814        | NodeResponse::NativeSessionIndexed { record, .. }
14815        | NodeResponse::SessionRecordResumed { record, .. } => {
14816            record.environment_profile = None;
14817        }
14818        NodeResponse::Armed { .. }
14819        | NodeResponse::Spawned { .. }
14820        | NodeResponse::Activated { .. }
14821        | NodeResponse::Aborted { .. }
14822        | NodeResponse::ReplyChunkAccepted { .. }
14823        | NodeResponse::CallRejected { .. }
14824        | NodeResponse::DeliveryStageBegun { .. }
14825        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14826        | NodeResponse::DeliveryCommitted { .. }
14827        | NodeResponse::DeliveryStageAborted { .. }
14828        | NodeResponse::WorkspaceInspected { .. }
14829        | NodeResponse::HostDirectoriesBrowsed { .. }
14830        | NodeResponse::WorkspaceFileRead { .. }
14831        | NodeResponse::WorkspaceFileWritten { .. }
14832        | NodeResponse::WorkspaceFileCreated { .. }
14833        | NodeResponse::WorkspaceDirectoryCreated { .. }
14834        | NodeResponse::GitHistoryRead { .. }
14835        | NodeResponse::GitDiffRead { .. }
14836        | NodeResponse::Controller { .. }
14837        | NodeResponse::SpawnAccepted { .. }
14838        | NodeResponse::SpawnSpecAccepted { .. }
14839        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14840        | NodeResponse::ManagedWorktreeCleanup { .. }
14841        | NodeResponse::NativeSessionsCataloged { .. }
14842        | NodeResponse::NativeSessionsPaged { .. }
14843        | NodeResponse::NativeSessionPreviewed { .. }
14844        | NodeResponse::SessionRecordPreviewed { .. }
14845        | NodeResponse::HistoryDiscovered { .. }
14846        | NodeResponse::HistoryLoaded { .. }
14847        | NodeResponse::ContextPackForSessionRecordExported { .. }
14848        | NodeResponse::ContextPackExported { .. }
14849        | NodeResponse::ContextPackForgotten { .. }
14850        | NodeResponse::DurableContextPackResolved { .. }
14851        | NodeResponse::ContextPackBytesRead { .. }
14852        | NodeResponse::SessionRecordForgotten { .. }
14853        | NodeResponse::WorkspaceRegistered { .. }
14854        | NodeResponse::StandaloneWorkspaceCreated { .. }
14855        | NodeResponse::WorkspaceUnregistered { .. }
14856        | NodeResponse::WorktreeCreated { .. }
14857        | NodeResponse::WorktreeRemoved { .. }
14858        | NodeResponse::Accepted
14859        | NodeResponse::ShuttingDown => {}
14860    }
14861}
14862
14863fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14864    for record in &mut snapshot.session_records {
14865        record.bundle = None;
14866    }
14867}
14868
14869fn project_event_without_session_bundle(
14870    mut envelope: NodeEventEnvelope,
14871) -> NodeEventEnvelope {
14872    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14873        record.bundle = None;
14874    }
14875    envelope
14876}
14877
14878fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14879    let contains_bundle = match reply.result.as_ref() {
14880        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14881        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14882            receipt.spawn.bundle.is_some()
14883        }
14884        _ => false,
14885    };
14886    if contains_bundle {
14887        reply.result = Err(failure(
14888            NodeFailureCode::UnsupportedCapability,
14889            "session bundles were not negotiated",
14890        ));
14891        return;
14892    }
14893    let Ok(response) = reply.result.as_mut() else {
14894        return;
14895    };
14896    match response {
14897        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14898        NodeResponse::Resync { snapshot, events, .. } => {
14899            clear_snapshot_session_bundles(snapshot);
14900            for event in events {
14901                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14902                    record.bundle = None;
14903                }
14904            }
14905        }
14906        NodeResponse::SessionRecordUpdated { record }
14907        | NodeResponse::ProviderSessionIndexed { record }
14908        | NodeResponse::NativeSessionIndexed { record, .. }
14909        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14910        NodeResponse::Armed { .. }
14911        | NodeResponse::Spawned { .. }
14912        | NodeResponse::Activated { .. }
14913        | NodeResponse::Aborted { .. }
14914        | NodeResponse::ReplyChunkAccepted { .. }
14915        | NodeResponse::CallRejected { .. }
14916        | NodeResponse::DeliveryStageBegun { .. }
14917        | NodeResponse::DeliveryBlobChunkAccepted { .. }
14918        | NodeResponse::DeliveryCommitted { .. }
14919        | NodeResponse::DeliveryStageAborted { .. }
14920        | NodeResponse::WorkspaceInspected { .. }
14921        | NodeResponse::HostDirectoriesBrowsed { .. }
14922        | NodeResponse::WorkspaceFileRead { .. }
14923        | NodeResponse::WorkspaceFileWritten { .. }
14924        | NodeResponse::WorkspaceFileCreated { .. }
14925        | NodeResponse::WorkspaceDirectoryCreated { .. }
14926        | NodeResponse::GitHistoryRead { .. }
14927        | NodeResponse::GitDiffRead { .. }
14928        | NodeResponse::Controller { .. }
14929        | NodeResponse::SpawnAccepted { .. }
14930        | NodeResponse::SpawnSpecAccepted { .. }
14931        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14932        | NodeResponse::ManagedWorktreeCleanup { .. }
14933        | NodeResponse::NativeSessionsCataloged { .. }
14934        | NodeResponse::NativeSessionsPaged { .. }
14935        | NodeResponse::NativeSessionPreviewed { .. }
14936        | NodeResponse::SessionRecordPreviewed { .. }
14937        | NodeResponse::HistoryDiscovered { .. }
14938        | NodeResponse::HistoryLoaded { .. }
14939        | NodeResponse::ContextPackForSessionRecordExported { .. }
14940        | NodeResponse::ContextPackExported { .. }
14941        | NodeResponse::ContextPackForgotten { .. }
14942        | NodeResponse::DurableContextPackResolved { .. }
14943        | NodeResponse::ContextPackBytesRead { .. }
14944        | NodeResponse::SessionRecordForgotten { .. }
14945        | NodeResponse::WorkspaceRegistered { .. }
14946        | NodeResponse::StandaloneWorkspaceCreated { .. }
14947        | NodeResponse::WorkspaceUnregistered { .. }
14948        | NodeResponse::WorktreeCreated { .. }
14949        | NodeResponse::WorktreeRemoved { .. }
14950        | NodeResponse::Accepted
14951        | NodeResponse::ShuttingDown => {}
14952    }
14953}
14954
14955fn project_snapshot_history_for_wire(
14956    snapshot: &mut NodeSnapshot,
14957    include_history_metadata: bool,
14958) {
14959    for workspace in &mut snapshot.workspaces {
14960        for session in &mut workspace.sessions {
14961            if include_history_metadata {
14962                session.history.loaded = None;
14963            } else {
14964                session.history = Default::default();
14965            }
14966        }
14967    }
14968}
14969
14970fn project_event_history_for_wire(
14971    mut envelope: NodeEventEnvelope,
14972    include_history_metadata: bool,
14973) -> NodeEventEnvelope {
14974    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14975        for session in &mut workspace.sessions {
14976            if include_history_metadata {
14977                session.history.loaded = None;
14978            } else {
14979                session.history = Default::default();
14980            }
14981        }
14982    }
14983    envelope
14984}
14985
14986fn project_response_history_for_wire(
14987    reply: &mut ResponseEnvelope,
14988    include_history_metadata: bool,
14989) {
14990    let Ok(response) = reply.result.as_mut() else {
14991        return;
14992    };
14993    match response {
14994        NodeResponse::Snapshot { snapshot, .. } => {
14995            project_snapshot_history_for_wire(snapshot, include_history_metadata);
14996        }
14997        NodeResponse::Resync {
14998            snapshot, events, ..
14999        } => {
15000            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15001            for event in events {
15002                *event = project_event_history_for_wire(
15003                    event.clone(),
15004                    include_history_metadata,
15005                );
15006            }
15007        }
15008        NodeResponse::WorkspaceRegistered { workspace }
15009        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15010        | NodeResponse::WorktreeCreated { workspace, .. } => {
15011            for session in &mut workspace.sessions {
15012                if include_history_metadata {
15013                    session.history.loaded = None;
15014                } else {
15015                    session.history = Default::default();
15016                }
15017            }
15018        }
15019        _ => {}
15020    }
15021}
15022
15023fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15024    for record in &mut snapshot.session_records {
15025        record.context_id = None;
15026        record.context = None;
15027        record.exported_context = None;
15028    }
15029}
15030
15031fn project_event_without_context_pack(
15032    mut envelope: NodeEventEnvelope,
15033) -> NodeEventEnvelope {
15034    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15035        record.context_id = None;
15036        record.context = None;
15037        record.exported_context = None;
15038    }
15039    envelope
15040}
15041
15042fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15043    let contains_context = match reply.result.as_ref() {
15044        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15045        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15046            receipt.spawn.context.is_some()
15047        }
15048        Ok(NodeResponse::HistoryDiscovered { .. })
15049        | Ok(NodeResponse::HistoryLoaded { .. })
15050        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15051        | Ok(NodeResponse::ContextPackExported { .. })
15052        | Ok(NodeResponse::ContextPackForgotten { .. })
15053        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15054        _ => false,
15055    };
15056    if contains_context {
15057        reply.result = Err(failure(
15058            NodeFailureCode::UnsupportedCapability,
15059            "history context packs were not negotiated",
15060        ));
15061        return;
15062    }
15063    let Ok(response) = reply.result.as_mut() else {
15064        return;
15065    };
15066    match response {
15067        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15068        NodeResponse::Resync {
15069            snapshot, events, ..
15070        } => {
15071            clear_snapshot_context_packs(snapshot);
15072            for event in events {
15073                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15074                    record.context_id = None;
15075                    record.context = None;
15076                    record.exported_context = None;
15077                }
15078            }
15079        }
15080        NodeResponse::SessionRecordUpdated { record }
15081        | NodeResponse::ProviderSessionIndexed { record }
15082        | NodeResponse::NativeSessionIndexed { record, .. }
15083        | NodeResponse::SessionRecordResumed { record, .. } => {
15084            record.context_id = None;
15085            record.context = None;
15086            record.exported_context = None;
15087        }
15088        _ => {}
15089    }
15090}
15091
15092fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15093    let contains_open_record = match reply.result.as_ref() {
15094        Ok(NodeResponse::SessionRecordUpdated { record })
15095        | Ok(NodeResponse::ProviderSessionIndexed { record })
15096        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15097            !provider_id_is_legacy(&record.provider)
15098        }
15099        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15100            !provider_id_is_legacy(&selection.route.provider)
15101                || !provider_id_is_legacy(&record.provider)
15102        }
15103        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15104            !provider_id_is_legacy(&receipt.provider)
15105        }
15106        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15107            !provider_id_is_legacy(&receipt.spawn.provider)
15108        }
15109        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15110        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15111            !provider_id_is_legacy(&route.provider)
15112        }
15113        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15114            !provider_id_is_legacy(&selection.route.provider)
15115        }
15116        _ => false,
15117    };
15118    if contains_open_record {
15119        reply.result = Err(failure(
15120            NodeFailureCode::UnsupportedCapability,
15121            "open provider IDs were not negotiated",
15122        ));
15123        return;
15124    }
15125    let Ok(response) = reply.result.as_mut() else {
15126        return;
15127    };
15128    match response {
15129        NodeResponse::Snapshot { snapshot, .. } => {
15130            project_snapshot_legacy_provider_ids(snapshot);
15131        }
15132        NodeResponse::Resync { snapshot, events, .. } => {
15133            project_snapshot_legacy_provider_ids(snapshot);
15134            *events = std::mem::take(events)
15135                .into_iter()
15136                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15137                .collect();
15138        }
15139        NodeResponse::WorkspaceRegistered { workspace }
15140        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15141        | NodeResponse::WorktreeCreated { workspace, .. } => {
15142            workspace
15143                .sessions
15144                .retain(|session| provider_id_is_legacy(&session.agent_id));
15145        }
15146        NodeResponse::Armed { .. }
15147        | NodeResponse::Spawned { .. }
15148        | NodeResponse::Activated { .. }
15149        | NodeResponse::Aborted { .. }
15150        | NodeResponse::ReplyChunkAccepted { .. }
15151        | NodeResponse::CallRejected { .. }
15152        | NodeResponse::DeliveryStageBegun { .. }
15153        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15154        | NodeResponse::DeliveryCommitted { .. }
15155        | NodeResponse::DeliveryStageAborted { .. }
15156        | NodeResponse::WorkspaceInspected { .. }
15157        | NodeResponse::HostDirectoriesBrowsed { .. }
15158        | NodeResponse::WorkspaceFileRead { .. }
15159        | NodeResponse::WorkspaceFileWritten { .. }
15160        | NodeResponse::WorkspaceFileCreated { .. }
15161        | NodeResponse::WorkspaceDirectoryCreated { .. }
15162        | NodeResponse::GitHistoryRead { .. }
15163        | NodeResponse::GitDiffRead { .. }
15164        | NodeResponse::Controller { .. }
15165        | NodeResponse::SpawnAccepted { .. }
15166        | NodeResponse::SpawnSpecAccepted { .. }
15167        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15168        | NodeResponse::ManagedWorktreeCleanup { .. }
15169        | NodeResponse::NativeSessionsCataloged { .. }
15170        | NodeResponse::NativeSessionsPaged { .. }
15171        | NodeResponse::NativeSessionPreviewed { .. }
15172        | NodeResponse::SessionRecordPreviewed { .. }
15173        | NodeResponse::HistoryDiscovered { .. }
15174        | NodeResponse::HistoryLoaded { .. }
15175        | NodeResponse::ContextPackForSessionRecordExported { .. }
15176        | NodeResponse::ContextPackExported { .. }
15177        | NodeResponse::ContextPackForgotten { .. }
15178        | NodeResponse::DurableContextPackResolved { .. }
15179        | NodeResponse::ContextPackBytesRead { .. }
15180        | NodeResponse::SessionRecordUpdated { .. }
15181        | NodeResponse::ProviderSessionIndexed { .. }
15182        | NodeResponse::NativeSessionIndexed { .. }
15183        | NodeResponse::SessionRecordResumed { .. }
15184        | NodeResponse::SessionRecordForgotten { .. }
15185        | NodeResponse::WorkspaceUnregistered { .. }
15186        | NodeResponse::WorktreeRemoved { .. }
15187        | NodeResponse::Accepted
15188        | NodeResponse::ShuttingDown => {}
15189    }
15190}
15191
15192fn project_event_legacy_provider_ids(
15193    shared: &NodeShared,
15194    mut envelope: NodeEventEnvelope,
15195) -> Option<NodeEventEnvelope> {
15196    let include = match &mut envelope.event {
15197        NodeEvent::HarnessMcpReadCall { .. } => true,
15198        NodeEvent::Control { address, .. }
15199        | NodeEvent::AgentStream { address, .. }
15200        | NodeEvent::TerminalFrame { address, .. } => shared
15201            .validate_address(address)
15202            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15203        NodeEvent::WorkspaceAdded { workspace } => {
15204            workspace
15205                .sessions
15206                .retain(|session| provider_id_is_legacy(&session.agent_id));
15207            true
15208        }
15209        NodeEvent::SessionRecordUpserted { record } => {
15210            provider_id_is_legacy(&record.provider)
15211        }
15212        NodeEvent::SessionRecordHistorySummarized { .. } => false,
15213        NodeEvent::SessionRecordRemoved { .. } => shared
15214            .history
15215            .lock()
15216            .unwrap_or_else(|poisoned| poisoned.into_inner())
15217            .removed_record_providers
15218            .get(&envelope.sequence)
15219            .is_some_and(provider_id_is_legacy),
15220        NodeEvent::ControllerChanged { .. }
15221        | NodeEvent::ManagedWorktreeUpserted { .. }
15222        | NodeEvent::ManagedWorktreeRemoved { .. }
15223        | NodeEvent::WorkspaceRemoved { .. }
15224        | NodeEvent::ResyncRequired { .. } => true,
15225    };
15226    include.then_some(envelope)
15227}
15228
15229fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15230    let Ok(response) = reply.result.as_mut() else {
15231        return;
15232    };
15233    match response {
15234        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15235            snapshot.provider_runtime_statuses.clear();
15236        }
15237        NodeResponse::Armed { .. }
15238        | NodeResponse::Spawned { .. }
15239        | NodeResponse::Activated { .. }
15240        | NodeResponse::Aborted { .. }
15241        | NodeResponse::ReplyChunkAccepted { .. }
15242        | NodeResponse::CallRejected { .. }
15243        | NodeResponse::DeliveryStageBegun { .. }
15244        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15245        | NodeResponse::DeliveryCommitted { .. }
15246        | NodeResponse::DeliveryStageAborted { .. }
15247        | NodeResponse::WorkspaceInspected { .. }
15248        | NodeResponse::HostDirectoriesBrowsed { .. }
15249        | NodeResponse::WorkspaceFileRead { .. }
15250        | NodeResponse::WorkspaceFileWritten { .. }
15251        | NodeResponse::WorkspaceFileCreated { .. }
15252        | NodeResponse::WorkspaceDirectoryCreated { .. }
15253        | NodeResponse::GitHistoryRead { .. }
15254        | NodeResponse::GitDiffRead { .. }
15255        | NodeResponse::Controller { .. }
15256        | NodeResponse::SpawnAccepted { .. }
15257        | NodeResponse::SpawnSpecAccepted { .. }
15258        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15259        | NodeResponse::ManagedWorktreeCleanup { .. }
15260        | NodeResponse::NativeSessionsCataloged { .. }
15261        | NodeResponse::NativeSessionsPaged { .. }
15262        | NodeResponse::NativeSessionPreviewed { .. }
15263        | NodeResponse::SessionRecordPreviewed { .. }
15264        | NodeResponse::HistoryDiscovered { .. }
15265        | NodeResponse::HistoryLoaded { .. }
15266        | NodeResponse::ContextPackForSessionRecordExported { .. }
15267        | NodeResponse::ContextPackExported { .. }
15268        | NodeResponse::ContextPackForgotten { .. }
15269        | NodeResponse::DurableContextPackResolved { .. }
15270        | NodeResponse::ContextPackBytesRead { .. }
15271        | NodeResponse::SessionRecordUpdated { .. }
15272        | NodeResponse::ProviderSessionIndexed { .. }
15273        | NodeResponse::NativeSessionIndexed { .. }
15274        | NodeResponse::SessionRecordResumed { .. }
15275        | NodeResponse::SessionRecordForgotten { .. }
15276        | NodeResponse::WorkspaceRegistered { .. }
15277        | NodeResponse::StandaloneWorkspaceCreated { .. }
15278        | NodeResponse::WorkspaceUnregistered { .. }
15279        | NodeResponse::WorktreeCreated { .. }
15280        | NodeResponse::WorktreeRemoved { .. }
15281        | NodeResponse::Accepted
15282        | NodeResponse::ShuttingDown => {}
15283    }
15284}
15285
15286struct ControllerReleaseGuard {
15287    shared: Arc<NodeShared>,
15288    connection_id: u64,
15289}
15290
15291impl Drop for ControllerReleaseGuard {
15292    fn drop(&mut self) {
15293        self.shared.release_controller(self.connection_id);
15294    }
15295}
15296
15297struct AbortTaskOnDrop(AbortHandle);
15298
15299impl Drop for AbortTaskOnDrop {
15300    fn drop(&mut self) {
15301        self.0.abort();
15302    }
15303}
15304
15305/// Reads an unsigned env-config override, following the pattern of every
15306/// other bounded knob in this module: missing/unparsable/zero falls back to
15307/// `default`, and the resolved value is always clamped to `max` so a bad
15308/// override cannot reopen a bound this budget exists to enforce.
15309fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15310    std::env::var(name)
15311        .ok()
15312        .and_then(|value| value.parse::<u64>().ok())
15313        .filter(|value| *value > 0)
15314        .unwrap_or(default)
15315        .min(max)
15316}
15317
15318fn workspace_inspection_time_budget_ms() -> u64 {
15319    env_config_bounded(
15320        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15321        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15322        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15323    )
15324}
15325
15326fn workspace_inspection_entry_cap() -> usize {
15327    env_config_bounded(
15328        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15329        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15330        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15331    ) as usize
15332}
15333
15334/// Tracks the shared walk-side budget across the whole recursive walk: a
15335/// wall-clock deadline (shared with the git phase that runs after the walk)
15336/// and a cap on directory entries visited, whether or not they were pushed
15337/// to the response. Cheap to check per entry — one `Instant::now()` and one
15338/// integer compare.
15339struct WorkspaceWalkBudget {
15340    deadline: Instant,
15341    entry_cap: usize,
15342    entries_visited: u64,
15343    time_budget_exceeded: bool,
15344    entry_cap_exceeded: bool,
15345}
15346
15347impl WorkspaceWalkBudget {
15348    fn new(deadline: Instant, entry_cap: usize) -> Self {
15349        Self {
15350            deadline,
15351            entry_cap,
15352            entries_visited: 0,
15353            time_budget_exceeded: false,
15354            entry_cap_exceeded: false,
15355        }
15356    }
15357
15358    fn is_exhausted(&self) -> bool {
15359        self.time_budget_exceeded || self.entry_cap_exceeded
15360    }
15361
15362    /// Records one visited directory entry (pushed to the response or not)
15363    /// and reports whether the walk must stop now.
15364    fn record_visit(&mut self) -> bool {
15365        self.entries_visited += 1;
15366        if self.entries_visited as usize >= self.entry_cap {
15367            self.entry_cap_exceeded = true;
15368        }
15369        if Instant::now() >= self.deadline {
15370            self.time_budget_exceeded = true;
15371        }
15372        self.is_exhausted()
15373    }
15374}
15375
15376fn collect_workspace_entries(
15377    root: &Path,
15378    deadline: Instant,
15379    entry_cap: usize,
15380) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15381    let mut entries = Vec::new();
15382    let mut truncated = false;
15383    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15384    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15385    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15386    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15387    // require strict global byte order over relative paths.
15388    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15389    (entries, truncated, budget)
15390}
15391
15392/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15393/// `inspect_workspace` response — `None` once the walk and git phase both
15394/// completed inside their shared budget.
15395fn workspace_inspection_truncation(
15396    walk_budget: &WorkspaceWalkBudget,
15397    git_time_budget_exceeded: bool,
15398    elapsed: Duration,
15399) -> Option<WorkspaceInspectionTruncationV1> {
15400    if !walk_budget.time_budget_exceeded
15401        && !walk_budget.entry_cap_exceeded
15402        && !git_time_budget_exceeded
15403    {
15404        return None;
15405    }
15406    Some(WorkspaceInspectionTruncationV1 {
15407        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15408        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15409        git_time_budget_exceeded,
15410        entries_visited: walk_budget.entries_visited,
15411        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15412    })
15413}
15414
15415fn walk_workspace_directory(
15416    directory: &Path,
15417    relative_directory: Option<&RepositoryPath>,
15418    depth: usize,
15419    entries: &mut Vec<WorkspaceEntry>,
15420    truncated: &mut bool,
15421    budget: &mut WorkspaceWalkBudget,
15422) {
15423    if budget.is_exhausted() {
15424        *truncated = true;
15425        return;
15426    }
15427    let read_dir = match std::fs::read_dir(directory) {
15428        Ok(read_dir) => read_dir,
15429        Err(_) => {
15430            *truncated = true;
15431            return;
15432        }
15433    };
15434    let mut children = Vec::new();
15435    for child in read_dir {
15436        match child {
15437            Ok(child) => children.push(child),
15438            Err(_) => *truncated = true,
15439        }
15440    }
15441    children.sort_by_key(|child| child.file_name());
15442    for child in children {
15443        if budget.record_visit() {
15444            *truncated = true;
15445            return;
15446        }
15447        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15448            *truncated = true;
15449            return;
15450        }
15451        let file_type = match child.file_type() {
15452            Ok(file_type) => file_type,
15453            Err(_) => {
15454                *truncated = true;
15455                continue;
15456            }
15457        };
15458        if file_type.is_symlink() {
15459            continue;
15460        }
15461        let name = match child.file_name().into_string() {
15462            Ok(name) => name,
15463            Err(_) => {
15464                *truncated = true;
15465                continue;
15466            }
15467        };
15468        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15469            continue;
15470        }
15471        let relative_path = match relative_directory {
15472            None => windows_repository_path(name),
15473            Some(parent) => parent
15474                .as_utf8()
15475                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15476        };
15477        let Some(relative_path) = relative_path else {
15478            *truncated = true;
15479            continue;
15480        };
15481        let kind = if file_type.is_dir() {
15482            WorkspaceEntryKind::Directory
15483        } else if file_type.is_file() {
15484            WorkspaceEntryKind::File
15485        } else {
15486            continue;
15487        };
15488        entries.push(WorkspaceEntry {
15489            relative_path: relative_path.clone(),
15490            kind,
15491        });
15492        if file_type.is_dir() {
15493            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15494                walk_workspace_directory(
15495                    &child.path(),
15496                    Some(&relative_path),
15497                    depth + 1,
15498                    entries,
15499                    truncated,
15500                    budget,
15501                );
15502            } else {
15503                *truncated = true;
15504            }
15505        }
15506    }
15507}
15508
15509fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15510    if value.contains('\\') {
15511        return None;
15512    }
15513    RepositoryPath::utf8(value).ok()
15514}
15515
15516fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15517    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15518}
15519
15520/// Excludes a directory from the workspace tree walk. The three literal
15521/// names are the common convention-based cases; the `CACHEDIR.TAG` check
15522/// beneath them catches every build-output directory regardless of name
15523/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
15524/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
15525/// directory without maintaining a name heuristic list). The name check
15526/// runs first and returns early so a directory rejected by name never pays
15527/// for the extra `CACHEDIR.TAG` stat.
15528fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15529    if name.eq_ignore_ascii_case(".git")
15530        || name.eq_ignore_ascii_case("target")
15531        || name.eq_ignore_ascii_case("node_modules")
15532    {
15533        return true;
15534    }
15535    path.join("CACHEDIR.TAG").is_file()
15536}
15537
15538fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15539    matches!(
15540        status,
15541        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15542    )
15543}
15544
15545#[derive(Clone, Debug, Eq, PartialEq)]
15546enum ContextPackRepositoryHead {
15547    NotRepository,
15548    Unborn,
15549    Commit(String),
15550}
15551
15552#[derive(Clone, Debug, Eq, PartialEq)]
15553struct ContextPackRepositoryObservation {
15554    head: ContextPackRepositoryHead,
15555    git: GitSnapshot,
15556    files: Vec<ContextPackRepositoryFileSource>,
15557}
15558
15559fn stable_context_pack_repository(
15560    first: ContextPackRepositoryObservation,
15561    second: ContextPackRepositoryObservation,
15562) -> Result<ContextPackRepository, NodeFailure> {
15563    if first != second {
15564        return Err(failure(
15565            NodeFailureCode::ContextPackMaterializationFailed,
15566            "context repository changed during bounded capture",
15567        ));
15568    }
15569    Ok(ContextPackRepository::from_sources(
15570        &second.git,
15571        second.files,
15572    ))
15573}
15574
15575/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
15576/// (and only once) the shared walk+git `deadline` has already passed. Every
15577/// remaining phase in `inspect_git_workspace` checks this immediately
15578/// before issuing its own git call, so a budget spent by the walk (or by an
15579/// earlier git call) skips the rest of the chain instead of paying for it
15580/// anyway.
15581fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15582    if Instant::now() < deadline {
15583        return false;
15584    }
15585    append_git_diagnostic(
15586        snapshot,
15587        &format!("{stage} skipped: workspace inspection time budget exhausted"),
15588    );
15589    true
15590}
15591
15592/// Deadline-aware sibling of `run_git_bounded`, used only by
15593/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
15594/// whatever remains of the shared `deadline` (never more than
15595/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
15596/// of the budget cannot still hand a full per-call timeout to every git
15597/// probe behind it. `inspect_git_workspace` already guards each call site
15598/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
15599/// passed yet here.
15600async fn run_git_bounded_with_deadline(
15601    root: &str,
15602    arguments: &[&str],
15603    output_limit: usize,
15604    deadline: Instant,
15605) -> io::Result<GitCommandOutput> {
15606    let remaining = deadline.checked_duration_since(Instant::now());
15607    let timeout_ms = remaining
15608        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15609        .unwrap_or(0)
15610        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15611    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15612}
15613
15614/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
15615/// `deadline` is the same absolute instant the preceding directory walk was
15616/// bounded by, so time already spent walking counts against what remains
15617/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
15618/// shared budget (not an individual per-call timeout) is what cut the git
15619/// phase short.
15620async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15621    let mut snapshot = GitSnapshot {
15622        is_repository: false,
15623        branch: None,
15624        status: Vec::new(),
15625        recent_commits: Vec::new(),
15626        worktrees: Vec::new(),
15627        managed_worktree: None,
15628        truncated: false,
15629        diagnostic: None,
15630    };
15631    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15632        return (snapshot, true);
15633    }
15634    let repository = match run_git_bounded_with_deadline(
15635        root,
15636        &["rev-parse", "--is-inside-work-tree"],
15637        4 * 1_024,
15638        deadline,
15639    )
15640    .await
15641    {
15642        Ok(output) => output,
15643        Err(error) => {
15644            append_git_diagnostic(
15645                &mut snapshot,
15646                &format!("git inspection unavailable: {error}"),
15647            );
15648            return (snapshot, false);
15649        }
15650    };
15651    snapshot.truncated |= repository.truncated;
15652    if repository.timed_out {
15653        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15654        return (snapshot, false);
15655    }
15656    if !repository.success
15657        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15658    {
15659        let stderr = String::from_utf8_lossy(&repository.stderr);
15660        if !stderr.to_ascii_lowercase().contains("not a git repository") {
15661            append_git_diagnostic(&mut snapshot, stderr.trim());
15662        }
15663        return (snapshot, false);
15664    }
15665    snapshot.is_repository = true;
15666
15667    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15668        return (snapshot, true);
15669    }
15670    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15671        Ok(output) => {
15672            snapshot.truncated |= output.truncated;
15673            if output.timed_out {
15674                append_git_diagnostic(&mut snapshot, "git branch query timed out");
15675            } else if output.success {
15676                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15677                if !branch.is_empty() {
15678                    snapshot.branch = Some(truncate_git_field(
15679                        &branch,
15680                        MAX_REPOSITORY_PATH_BYTES,
15681                        &mut snapshot.truncated,
15682                    ));
15683                }
15684            }
15685        }
15686        Err(error) => append_git_diagnostic(
15687            &mut snapshot,
15688            &format!("git branch query failed: {error}"),
15689        ),
15690    }
15691    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15692        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15693            snapshot.truncated |= output.truncated;
15694            if output.success && !output.timed_out {
15695                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15696                if !head.is_empty() {
15697                    snapshot.branch = Some(format!("detached:{head}"));
15698                }
15699            }
15700        }
15701    }
15702
15703    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15704        return (snapshot, true);
15705    }
15706    match run_git_bounded_with_deadline(
15707        root,
15708        &[
15709            "status",
15710            "--porcelain=v1",
15711            "-z",
15712            "--renames",
15713            "--untracked-files=normal",
15714            "--",
15715            ".",
15716        ],
15717        GIT_OUTPUT_MAX_BYTES,
15718        deadline,
15719    )
15720    .await
15721    {
15722        Ok(output) => {
15723            snapshot.truncated |= output.truncated;
15724            if output.timed_out {
15725                append_git_diagnostic(&mut snapshot, "git status timed out");
15726            } else if output.success {
15727                parse_git_status(&output.stdout, &mut snapshot);
15728            } else {
15729                append_git_diagnostic(
15730                    &mut snapshot,
15731                    String::from_utf8_lossy(&output.stderr).trim(),
15732                );
15733            }
15734        }
15735        Err(error) => append_git_diagnostic(
15736            &mut snapshot,
15737            &format!("git status failed: {error}"),
15738        ),
15739    }
15740
15741    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15742        return (snapshot, true);
15743    }
15744    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15745    match run_git_bounded_with_deadline(
15746        root,
15747        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15748        16 * 1_024,
15749        deadline,
15750    )
15751    .await
15752    {
15753        Ok(output) => {
15754            snapshot.truncated |= output.truncated;
15755            if output.timed_out {
15756                append_git_diagnostic(&mut snapshot, "git log timed out");
15757            } else if output.success {
15758                parse_git_commits(&output.stdout, &mut snapshot);
15759            } else {
15760                let stderr = String::from_utf8_lossy(&output.stderr);
15761                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15762                    append_git_diagnostic(&mut snapshot, stderr.trim());
15763                }
15764            }
15765        }
15766        Err(error) => append_git_diagnostic(
15767            &mut snapshot,
15768            &format!("git log failed: {error}"),
15769        ),
15770    }
15771
15772    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15773        return (snapshot, true);
15774    }
15775    match list_git_worktrees_with_deadline(root, deadline).await {
15776        Ok(worktrees) => {
15777            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15778        }
15779        Err(error) => append_git_diagnostic(
15780            &mut snapshot,
15781            &format!("git worktree list failed: {}", error.message),
15782        ),
15783    }
15784    (snapshot, false)
15785}
15786
15787fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15788    let mut remaining = output;
15789    while !remaining.is_empty() {
15790        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15791            snapshot.truncated = true;
15792            break;
15793        }
15794        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15795            snapshot.truncated = true;
15796            break;
15797        };
15798        let record = &remaining[..record_end];
15799        remaining = &remaining[record_end + 1..];
15800        let has_previous_path = record
15801            .get(..2)
15802            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15803        let previous_path_bytes = if has_previous_path {
15804            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15805                snapshot.truncated = true;
15806                break;
15807            };
15808            let previous = &remaining[..previous_end];
15809            remaining = &remaining[previous_end + 1..];
15810            Some(previous)
15811        } else {
15812            None
15813        };
15814        if record.len() < 4
15815            || record[2] != b' '
15816            || !is_git_status_code(record[0])
15817            || !is_git_status_code(record[1])
15818        {
15819            snapshot.truncated = true;
15820            continue;
15821        }
15822        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15823            snapshot.truncated = true;
15824            continue;
15825        };
15826        let previous_path = match previous_path_bytes {
15827            Some(previous) => {
15828                let Some(previous) = windows_repository_path_from_bytes(previous) else {
15829                    snapshot.truncated = true;
15830                    continue;
15831                };
15832                Some(previous)
15833            }
15834            None => None,
15835        };
15836        snapshot.status.push(GitStatusEntry {
15837            index_status: (record[0] as char).to_string(),
15838            worktree_status: (record[1] as char).to_string(),
15839            path,
15840            previous_path,
15841        });
15842    }
15843}
15844
15845fn is_git_status_code(byte: u8) -> bool {
15846    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15847}
15848
15849fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15850    for line in String::from_utf8_lossy(output).lines() {
15851        let Some((id, summary)) = line.split_once('\u{1f}') else {
15852            continue;
15853        };
15854        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15855            snapshot.truncated = true;
15856            break;
15857        }
15858        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15859        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15860        snapshot.recent_commits.push(GitCommitSummary { id, summary });
15861    }
15862}
15863
15864fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15865    if value.len() <= max_bytes {
15866        return value.to_owned();
15867    }
15868    *truncated = true;
15869    let mut boundary = max_bytes;
15870    while boundary > 0 && !value.is_char_boundary(boundary) {
15871        boundary -= 1;
15872    }
15873    value[..boundary].to_owned()
15874}
15875
15876fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15877    if message.is_empty() {
15878        return;
15879    }
15880    let normalized = message
15881        .chars()
15882        .map(|character| if character.is_control() { ' ' } else { character })
15883        .collect::<String>();
15884    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15885    if !diagnostic.is_empty() {
15886        diagnostic.push_str("; ");
15887    }
15888    diagnostic.push_str(normalized.trim());
15889    snapshot.diagnostic = Some(truncate_git_field(
15890        &diagnostic,
15891        GIT_DIAGNOSTIC_MAX_BYTES,
15892        &mut snapshot.truncated,
15893    ));
15894}
15895
15896async fn run_git_bounded(
15897    root: &str,
15898    arguments: &[&str],
15899    output_limit: usize,
15900) -> io::Result<GitCommandOutput> {
15901    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15902}
15903
15904async fn read_git_history_bounded(
15905    root: &str,
15906    path: Option<&RepositoryPath>,
15907    before: Option<&GitObjectId>,
15908    limit: u16,
15909) -> Result<GitHistoryPage, NodeFailure> {
15910    let path = path
15911        .map(|path| {
15912            path.as_utf8().ok_or_else(|| {
15913                failure(
15914                    NodeFailureCode::InvalidRepositoryPath,
15915                    "git history path must use UTF-8",
15916                )
15917            })
15918        })
15919        .transpose()?
15920        .unwrap_or(".");
15921    let count = (usize::from(limit) + 1).to_string();
15922    let start = before
15923        .map(|revision| format!("{}^", revision.as_str()))
15924        .unwrap_or_else(|| "HEAD".to_owned());
15925    let arguments = [
15926        "--no-pager",
15927        "log",
15928        "--no-decorate",
15929        "--date=iso-strict",
15930        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15931        "-n",
15932        count.as_str(),
15933        start.as_str(),
15934        "--",
15935        path,
15936    ];
15937    let output = run_git_bounded(root, &arguments, 256 * 1_024)
15938        .await
15939        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15940    if output.timed_out {
15941        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15942    }
15943    if !output.success {
15944        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15945    }
15946    let mut commits = parse_git_history(&output.stdout);
15947    let has_more = commits.len() > usize::from(limit) || output.truncated;
15948    commits.truncate(usize::from(limit));
15949    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15950    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15951}
15952
15953fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15954    String::from_utf8_lossy(output)
15955        .split('\u{1e}')
15956        .filter_map(|record| {
15957            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15958            if fields.len() != 9 {
15959                return None;
15960            }
15961            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15962            let parents = if fields[1].is_empty() {
15963                Vec::new()
15964            } else {
15965                fields[1]
15966                    .split(' ')
15967                    .map(|value| GitObjectId::new(value.to_owned()))
15968                    .collect::<Result<Vec<_>, _>>()
15969                    .ok()?
15970            };
15971            Some(GitCommitDetails {
15972                id,
15973                parents,
15974                subject: bounded_git_text(fields[2], 512),
15975                author_name: bounded_git_text(fields[3], 256),
15976                author_email: bounded_git_text(fields[4], 320),
15977                authored_at: bounded_git_text(fields[5], 64),
15978                committer_name: bounded_git_text(fields[6], 256),
15979                committer_email: bounded_git_text(fields[7], 320),
15980                committed_at: bounded_git_text(fields[8], 64),
15981                signature_status: GitSignatureStatus::NoSignature,
15982                signer: None,
15983            })
15984        })
15985        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15986        .collect()
15987}
15988
15989fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15990    let normalized = value
15991        .chars()
15992        .map(|character| if character.is_control() { ' ' } else { character })
15993        .collect::<String>();
15994    if normalized.len() <= max_bytes {
15995        return normalized;
15996    }
15997    let mut boundary = max_bytes;
15998    while boundary > 0 && !normalized.is_char_boundary(boundary) {
15999        boundary -= 1;
16000    }
16001    normalized[..boundary].to_owned()
16002}
16003
16004async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16005    let path = request.path.as_ref().map(|path| {
16006        path.as_utf8()
16007            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16008    }).transpose()?;
16009    let mut arguments = match &request.mode {
16010        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16011        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16012        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16013    };
16014    if let GitDiffMode::Commit { revision } = &request.mode {
16015        arguments.push(revision.as_str());
16016    }
16017    arguments.push("--");
16018    if let Some(path) = path {
16019        arguments.push(path);
16020    }
16021    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16022        .await
16023        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16024    if output.timed_out {
16025        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16026    }
16027    if !output.success {
16028        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16029    }
16030    Ok(GitDiff {
16031        mode: request.mode,
16032        path: request.path,
16033        text: String::from_utf8_lossy(&output.stdout).into_owned(),
16034        truncated: output.truncated,
16035    })
16036}
16037
16038async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16039    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16040    ResponseEnvelope { request_id: envelope.request_id, result }
16041}
16042
16043async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16044    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16045        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16046    }
16047    let read_only = matches!(
16048        &request,
16049        NodeRequest::Snapshot
16050            | NodeRequest::Resync { .. }
16051            | NodeRequest::BrowseHostDirectories { .. }
16052            | NodeRequest::InspectWorkspace { .. }
16053            | NodeRequest::ReadWorkspaceFile { .. }
16054            | NodeRequest::ReadGitHistory { .. }
16055            | NodeRequest::ReadGitDiff { .. }
16056            | NodeRequest::CatalogNativeSessions { .. }
16057            | NodeRequest::PageNativeSessions { .. }
16058            | NodeRequest::PreviewNativeSession { .. }
16059            | NodeRequest::PreviewSessionRecord { .. }
16060    );
16061    let _mutation_guard = if read_only {
16062        None
16063    } else {
16064        let guard = shared.mutation_gate.lock().await;
16065        if shared.shutdown.load(Ordering::Acquire) {
16066            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16067        }
16068        Some(guard)
16069    };
16070    match request {
16071        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16072            event_sequence: shared.current_sequence(),
16073            controller: shared.controller_state(),
16074            snapshot: shared.snapshot(),
16075        }),
16076        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16077        NodeRequest::ArmHarnessMcpReservation {
16078            reservation_id,
16079            activation_digest,
16080            spawn_spec,
16081            launch,
16082            expires_at_unix_ms,
16083        } => {
16084            shared.require_controller(connection_id, role)?;
16085            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16086                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16087            })?;
16088            let expires_at_unix_ms = registry
16089                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16090                .await
16091                .map_err(harness_mcp_failure)?;
16092            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16093        }
16094        NodeRequest::SpawnSpecWithHarnessMcp {
16095            reservation_id,
16096            activation_digest,
16097            spec,
16098            deadline_unix_ms,
16099        } => {
16100            shared.require_controller(connection_id, role)?;
16101            let receipt = shared
16102                .spawn_from_spec_with_harness_mcp(
16103                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16104                )
16105                .await?;
16106            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16107        }
16108        NodeRequest::ActivateHarnessMcpReservation {
16109            reservation_id,
16110            activation_digest,
16111            record_id,
16112            session,
16113        } => {
16114            shared.require_controller(connection_id, role)?;
16115            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16116                current.instance_id == session.session.instance_id
16117                    && current.generation == session.session.generation
16118            }).and_then(|current| current.process_id).ok_or_else(|| {
16119                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16120            })?;
16121            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16122                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16123            })?.activate(
16124                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16125            ).map_err(harness_mcp_failure)?;
16126            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16127        }
16128        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16129            shared.require_controller(connection_id, role)?;
16130            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16131                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16132            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16133            if let Some(instance_id) = instance_id {
16134                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16135            }
16136            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16137        }
16138        NodeRequest::PutHarnessMcpReplyChunk {
16139            reservation_id, activation_digest, record_id, session, call_id,
16140            offset, final_chunk, chunk_hex,
16141        } => {
16142            shared.require_controller(connection_id, role)?;
16143            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16144                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16145            })?.put_reply_chunk(
16146                &reservation_id, &activation_digest, &record_id, &session, &call_id,
16147                offset, final_chunk, &chunk_hex,
16148            ).map_err(harness_mcp_failure)?;
16149            Ok(NodeResponse::ReplyChunkAccepted {
16150                reservation_id, activation_digest, record_id, session, call_id,
16151                next_offset, completed,
16152            })
16153        }
16154        NodeRequest::RejectHarnessMcpCall {
16155            reservation_id, activation_digest, record_id, session, call_id, reason,
16156        } => {
16157            shared.require_controller(connection_id, role)?;
16158            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16159                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16160            })?.reject_call(
16161                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16162            ).map_err(harness_mcp_failure)?;
16163            Ok(NodeResponse::CallRejected {
16164                reservation_id, activation_digest, record_id, session, call_id,
16165            })
16166        }
16167        NodeRequest::BeginDeliveryStage { manifest } => {
16168            shared.require_controller(connection_id, role)?;
16169            shared.begin_delivery_stage(manifest)
16170        }
16171        NodeRequest::PutDeliveryBlobChunk {
16172            stage_id,
16173            blob_digest,
16174            offset,
16175            chunk_hex,
16176        } => {
16177            shared.require_controller(connection_id, role)?;
16178            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16179        }
16180        NodeRequest::CommitDeliveryStage { stage_id } => {
16181            shared.require_controller(connection_id, role)?;
16182            let receipt = shared.commit_delivery_stage(stage_id)?;
16183            Ok(NodeResponse::DeliveryCommitted { receipt })
16184        }
16185        NodeRequest::AbortDeliveryStage { stage_id } => {
16186            shared.require_controller(connection_id, role)?;
16187            shared.abort_delivery_stage(&stage_id)?;
16188            Ok(NodeResponse::DeliveryStageAborted { stage_id })
16189        }
16190        NodeRequest::BrowseHostDirectories { directory, after } => {
16191            let listing = shared.browse_host_directories(directory, after).await?;
16192            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16193        }
16194        NodeRequest::InspectWorkspace { workspace_id } => {
16195            let inspection = shared.inspect_workspace(workspace_id).await?;
16196            Ok(NodeResponse::WorkspaceInspected { inspection })
16197        }
16198        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16199            let file = shared.read_workspace_file(workspace_id, path).await?;
16200            Ok(NodeResponse::WorkspaceFileRead { file })
16201        }
16202        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16203            shared.require_controller(connection_id, role)?;
16204            let file = shared
16205                .write_workspace_file(workspace_id, path, expected_revision, text)
16206                .await?;
16207            Ok(NodeResponse::WorkspaceFileWritten { file })
16208        }
16209        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16210            shared.require_controller(connection_id, role)?;
16211            let file = shared.create_workspace_file(workspace_id, path).await?;
16212            Ok(NodeResponse::WorkspaceFileCreated { file })
16213        }
16214        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16215            shared.require_controller(connection_id, role)?;
16216            let entry = shared
16217                .create_workspace_directory(workspace_id.clone(), path)
16218                .await?;
16219            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16220        }
16221        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16222            if role != ClientRole::Operator {
16223                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16224            }
16225            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16226                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16227            }
16228            let page = shared
16229                .read_git_history(workspace_id.clone(), path, before, limit)
16230                .await?;
16231            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16232        }
16233        NodeRequest::ReadGitDiff { workspace_id, request } => {
16234            if role != ClientRole::Operator {
16235                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16236            }
16237            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16238            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16239        }
16240        NodeRequest::CatalogNativeSessions { route, limit } => {
16241            if role != ClientRole::Operator {
16242                return Err(failure(
16243                    NodeFailureCode::ObserverReadOnly,
16244                    "native session catalog requires an operator connection",
16245                ));
16246            }
16247            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16248                return Err(failure(
16249                    NodeFailureCode::InvalidRequest,
16250                    "native session catalog limit is invalid",
16251                ));
16252            }
16253            let (entries, summary) = shared
16254                .catalog_native_sessions(route.clone(), limit)
16255                .await?;
16256            Ok(NodeResponse::NativeSessionsCataloged {
16257                route,
16258                entries,
16259                summary: Some(summary),
16260            })
16261        }
16262        NodeRequest::PageNativeSessions {
16263            route,
16264            window,
16265            catalog_revision,
16266            recent_cutoff_unix_ms,
16267            after_selection_id,
16268            limit,
16269        } => {
16270            if role != ClientRole::Operator {
16271                return Err(failure(
16272                    NodeFailureCode::ObserverReadOnly,
16273                    "native session catalog paging requires an operator connection",
16274                ));
16275            }
16276            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16277                || after_selection_id
16278                    .as_deref()
16279                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16280            {
16281                return Err(failure(
16282                    NodeFailureCode::InvalidRequest,
16283                    "native session catalog page request is invalid",
16284                ));
16285            }
16286            let page = shared
16287                .page_native_sessions(
16288                    route.clone(),
16289                    window,
16290                    catalog_revision,
16291                    recent_cutoff_unix_ms,
16292                    after_selection_id,
16293                    limit,
16294                )
16295                .await?;
16296            Ok(NodeResponse::NativeSessionsPaged {
16297                route,
16298                page,
16299            })
16300        }
16301        NodeRequest::PreviewNativeSession {
16302            selection,
16303            message_limit,
16304        } => {
16305            if role != ClientRole::Operator {
16306                return Err(failure(
16307                    NodeFailureCode::ObserverReadOnly,
16308                    "native session preview requires an operator connection",
16309                ));
16310            }
16311            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16312                .contains(&message_limit)
16313                || selection.validate().is_err()
16314            {
16315                return Err(failure(
16316                    NodeFailureCode::InvalidRequest,
16317                    "native session preview request is invalid",
16318                ));
16319            }
16320            let preview = shared
16321                .preview_native_session(selection.clone(), message_limit)
16322                .await?;
16323            Ok(NodeResponse::NativeSessionPreviewed {
16324                selection,
16325                preview,
16326            })
16327        }
16328        NodeRequest::IndexNativeSession {
16329            selection,
16330            display_name,
16331        } => {
16332            shared.require_controller(connection_id, role)?;
16333            let record = shared
16334                .index_native_session(selection.clone(), display_name)
16335                .await?;
16336            Ok(NodeResponse::NativeSessionIndexed { selection, record })
16337        }
16338        NodeRequest::PreviewSessionRecord {
16339            record_id,
16340            message_limit,
16341        } => {
16342            if role != ClientRole::Operator {
16343                return Err(failure(
16344                    NodeFailureCode::ObserverReadOnly,
16345                    "native session preview requires an operator connection",
16346                ));
16347            }
16348            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16349                .contains(&message_limit)
16350            {
16351                return Err(failure(
16352                    NodeFailureCode::InvalidRequest,
16353                    "native session preview request is invalid",
16354                ));
16355            }
16356            let record = shared.record(&record_id)?;
16357            let working_directory = shared.workspace_root(&record.workspace_id)?;
16358            if !platform::roots_equal(
16359                &working_directory,
16360                windows_path_text(&record.canonical_root),
16361            ) {
16362                return Err(failure(
16363                    NodeFailureCode::SessionRecordConflict,
16364                    "managed session workspace root changed; refusing to preview another directory",
16365                ));
16366            }
16367            let identity = record.provider_session.clone().ok_or_else(|| {
16368                failure(
16369                    NodeFailureCode::BackendOperationFailed,
16370                    "native session preview is unavailable for this session record",
16371                )
16372            })?;
16373            if identity.key != ProviderSessionKey::SessionId {
16374                return Err(failure(
16375                    NodeFailureCode::BackendOperationFailed,
16376                    "native session preview is unavailable for this session identity",
16377                ));
16378            }
16379            let preview = shared
16380                .preview_native_session_by_session_id(
16381                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16382                )
16383                .await?;
16384            if preview.session_id != identity.id {
16385                return Err(failure(
16386                    NodeFailureCode::SessionRecordConflict,
16387                    "native session preview identity changed while loading",
16388                ));
16389            }
16390            shared.revalidate_session_record_preview(&record, &identity)?;
16391            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16392            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16393                record_id: record_id.clone(),
16394                summary: SessionHistorySummaryV1::from(&preview),
16395            });
16396            Ok(NodeResponse::SessionRecordPreviewed {
16397                record_id,
16398                preview,
16399            })
16400        }
16401        NodeRequest::AcquireController { lease_ms } => {
16402            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16403            Ok(NodeResponse::Controller { controller: Some(controller) })
16404        }
16405        NodeRequest::ReleaseController => {
16406            shared.release_controller(connection_id);
16407            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16408        }
16409        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16410            shared.require_controller(connection_id, role)?;
16411            let root = require_windows_path(root)?;
16412            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16413            let workspace = shared.register_workspace(workspace_id, root).await?;
16414            Ok(NodeResponse::WorkspaceRegistered { workspace })
16415        }
16416        NodeRequest::CreateStandaloneWorkspace {
16417            workspace_id,
16418            root,
16419            initial_branch,
16420        } => {
16421            shared.require_controller(connection_id, role)?;
16422            let root = require_windows_path(root)?;
16423            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16424            let workspace = shared
16425                .create_standalone_workspace(workspace_id, root, initial_branch)
16426                .await?;
16427            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16428        }
16429        NodeRequest::UnregisterWorkspace { workspace_id } => {
16430            shared.require_controller(connection_id, role)?;
16431            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16432            shared.unregister_workspace(&workspace_id)?;
16433            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16434        }
16435        NodeRequest::CreateWorktree {
16436            source_workspace_id,
16437            workspace_id,
16438            target_root,
16439            branch,
16440            base,
16441        } => {
16442            shared.require_controller(connection_id, role)?;
16443            let target_root = require_windows_path(target_root)?;
16444            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16445            let (worktree, workspace) = shared
16446                .create_worktree(
16447                    source_workspace_id,
16448                    workspace_id,
16449                    target_root,
16450                    branch,
16451                    base,
16452                )
16453                .await?;
16454            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16455        }
16456        NodeRequest::RemoveWorktree {
16457            source_workspace_id,
16458            target_root,
16459        } => {
16460            shared.require_controller(connection_id, role)?;
16461            let native_target_root = require_windows_path(target_root.clone())?;
16462            shared.reject_managed_reservation(None, Some(&native_target_root))?;
16463            let workspace_id = shared
16464                .remove_worktree(source_workspace_id, native_target_root)
16465                .await?;
16466            Ok(NodeResponse::WorktreeRemoved {
16467                target_root,
16468                workspace_id,
16469            })
16470        }
16471        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16472            shared.require_controller(connection_id, role)?;
16473            if !terminal_size.is_valid() {
16474                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16475            }
16476            if let Some(prompt) = initial_prompt.as_deref() {
16477                validate_node_text("spawn initial prompt", prompt)?;
16478            }
16479            match shared
16480                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16481                .await
16482            {
16483                Ok(session) => {
16484                    shared.mark_spawn_provisional(session.clone());
16485                    Ok(NodeResponse::SpawnAccepted { session })
16486                }
16487                Err(err) => {
16488                    tracing::warn!(
16489                        %provider,
16490                        %workspace_id,
16491                        ?mode,
16492                        code = ?err.code,
16493                        message = %err.message,
16494                        "session spawn refused"
16495                    );
16496                    Err(err)
16497                }
16498            }
16499        }
16500        NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16501            shared.require_controller(connection_id, role)?;
16502            if !terminal_size.is_valid() {
16503                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16504            }
16505            if let Some(prompt) = initial_prompt.as_deref() {
16506                validate_node_text("spawn initial prompt", prompt)?;
16507            }
16508            let plan = backend_paths::plan_launch_options(
16509                provider.as_str(),
16510                mode,
16511                options.model.is_some(),
16512                options.effort.is_some(),
16513            )
16514            .map_err(|r| {
16515                tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16516                failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16517            })?;
16518            let selection = spawn_selection(&options)?;
16519            let pty_selection = match plan {
16520                backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16521                _ => None,
16522            };
16523            let spawned = SPAWN_SESSION_OPTIONS
16524                .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16525                .await;
16526            match spawned {
16527                Ok(session) => {
16528                    if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16529                        shared
16530                            .pending_acp_options
16531                            .lock()
16532                            .unwrap_or_else(|p| p.into_inner())
16533                            .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16534                    }
16535                    shared.mark_spawn_provisional(session.clone());
16536                    tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
16537                    Ok(NodeResponse::SpawnAccepted { session })
16538                }
16539                Err(err) => {
16540                    tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
16541                    Err(err)
16542                }
16543            }
16544        }
16545        NodeRequest::SpawnSpec { spec } => {
16546            shared.require_controller(connection_id, role)?;
16547            let receipt = shared.spawn_from_spec(spec).await?;
16548            Ok(NodeResponse::SpawnSpecAccepted { receipt })
16549        }
16550        NodeRequest::SpawnManagedWorktree { request } => {
16551            shared.require_controller(connection_id, role)?;
16552            let receipt = shared.spawn_managed_worktree(request).await?;
16553            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16554        }
16555        NodeRequest::SpawnManagedWorktreeV2 { request } => {
16556            shared.require_controller(connection_id, role)?;
16557            let receipt = shared.spawn_managed_worktree_v2(request).await?;
16558            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16559        }
16560        NodeRequest::CleanupManagedWorktree { lease_id } => {
16561            shared.require_controller(connection_id, role)?;
16562            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16563            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16564        }
16565        NodeRequest::DiscoverHistory { session, limit } => {
16566            controlled_session(shared, connection_id, role, &session)?;
16567            let candidates = shared.discover_history(&session, limit).await?;
16568            Ok(NodeResponse::HistoryDiscovered {
16569                session,
16570                candidates,
16571            })
16572        }
16573        NodeRequest::LoadHistory {
16574            session,
16575            candidate_id,
16576        } => {
16577            controlled_session(shared, connection_id, role, &session)?;
16578            let loaded = shared
16579                .load_history(&session, candidate_id)
16580                .await?;
16581            Ok(NodeResponse::HistoryLoaded {
16582                session,
16583                session_id: loaded.session_id,
16584                message_count: loaded.message_count,
16585                completed_turn_count: loaded.completed_turn_count,
16586            })
16587        }
16588        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16589            shared.require_controller(connection_id, role)?;
16590            let context = shared
16591                .export_context_pack_for_session_record(&record_id, &session, false)
16592                .await?;
16593            Ok(NodeResponse::ContextPackForSessionRecordExported {
16594                record_id,
16595                session,
16596                context,
16597            })
16598        }
16599        NodeRequest::ExportContextPack { session } => {
16600            controlled_session(shared, connection_id, role, &session)?;
16601            let context = shared.export_context_pack(&session).await?;
16602            Ok(NodeResponse::ContextPackExported { context })
16603        }
16604        NodeRequest::ForgetContextPack { context_id } => {
16605            shared.require_controller(connection_id, role)?;
16606            shared.forget_context_pack(&context_id)?;
16607            Ok(NodeResponse::ContextPackForgotten { context_id })
16608        }
16609        NodeRequest::ResolveDurableContextPack { context_id } => {
16610            // Read-only, idempotent, no controller lease required: this lets
16611            // a caller preflight "is this durably exported pack still here"
16612            // without holding the Node's mutation controller.
16613            let context = shared
16614                .context_catalog
16615                .read()
16616                .unwrap_or_else(|poisoned| poisoned.into_inner())
16617                .get(&context_id)
16618                .map(|pack| pack.receipt().clone())
16619                .ok_or_else(|| {
16620                    failure(
16621                        NodeFailureCode::UnknownContextPack,
16622                        "context pack is not present",
16623                    )
16624                })?;
16625            Ok(NodeResponse::DurableContextPackResolved { context })
16626        }
16627        NodeRequest::ReadContextPack { digest } => {
16628            // Read-only fetch of the pack's own bytes, keyed by content digest
16629            // rather than the caller's `SpawnContextId`: same no-controller-lease
16630            // rationale as `ResolveDurableContextPack` above, one step further
16631            // down the same catalog.
16632            let (id, byte_len, bytes) = shared
16633                .context_catalog
16634                .read()
16635                .unwrap_or_else(|poisoned| poisoned.into_inner())
16636                .get_by_digest(&digest)
16637                .map(|pack| {
16638                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16639                })
16640                .ok_or_else(|| {
16641                    failure(
16642                        NodeFailureCode::UnknownContextPack,
16643                        &format!("context pack {digest} not on this node"),
16644                    )
16645                })?;
16646            tracing::debug!(
16647                digest = digest.as_str(),
16648                byte_len,
16649                "read context pack bytes",
16650            );
16651            Ok(NodeResponse::ContextPackBytesRead {
16652                pack: ContextPackBytesRead {
16653                    digest,
16654                    id,
16655                    byte_len,
16656                    bytes,
16657                },
16658            })
16659        }
16660        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16661            let provider = controlled_session(shared, connection_id, role, &session)?;
16662            if let Some(prompt) = initial_prompt.as_deref() {
16663                validate_node_text("resume initial prompt", prompt)?;
16664            }
16665            let runtime_requirement = if initial_prompt.is_some() {
16666                ProviderRuntimeRequirement::ResumeWithPrompt
16667            } else {
16668                ProviderRuntimeRequirement::Resume
16669            };
16670            shared.require_session_runtime_policy(&session, runtime_requirement)?;
16671            let runtime_policy = shared
16672                .admit_provider_runtime(&provider, runtime_requirement)
16673                .await?;
16674            let working_directory = shared.bound_workspace_root(&session)?;
16675            let request = ResumeLaunchRequest {
16676                working_directory,
16677                terminal_size,
16678                initial_prompt,
16679            };
16680            request
16681                .validate()
16682                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16683            let command = shared.prepare_command(ControlCommand::Resume {
16684                instance_id: session.session.instance_id,
16685                target: ResumeTarget::CurrentProvider,
16686                runtime_policy,
16687                request,
16688            });
16689            shared.arm_resume(&session, command.id, runtime_policy)?;
16690            let dispatch = shared.dispatch_envelope(command);
16691            if let Err(error) = dispatch {
16692                shared.clear_armed_resume(&session);
16693                return Err(error);
16694            }
16695            Ok(NodeResponse::Accepted)
16696        }
16697        NodeRequest::RenameSessionRecord {
16698            record_id,
16699            display_name,
16700        } => {
16701            shared.require_controller(connection_id, role)?;
16702            let record = shared.rename_session_record(&record_id, display_name)?;
16703            Ok(NodeResponse::SessionRecordUpdated { record })
16704        }
16705        NodeRequest::SetSessionTask {
16706            record_id,
16707            expected_revision,
16708            target,
16709        } => {
16710            shared.require_controller(connection_id, role)?;
16711            let record = shared.set_session_task(&record_id, expected_revision, target)?;
16712            Ok(NodeResponse::SessionRecordUpdated { record })
16713        }
16714        NodeRequest::IndexProviderSession {
16715            workspace_id,
16716            provider,
16717            identity,
16718            display_name,
16719        } => {
16720            shared.require_controller(connection_id, role)?;
16721            let record = shared.index_provider_session(
16722                workspace_id,
16723                provider,
16724                identity,
16725                display_name,
16726            )?;
16727            Ok(NodeResponse::ProviderSessionIndexed { record })
16728        }
16729        NodeRequest::ResumeSessionRecord {
16730            record_id,
16731            terminal_size,
16732            initial_prompt,
16733        } => {
16734            shared.require_controller(connection_id, role)?;
16735            if let Some(prompt) = initial_prompt.as_deref() {
16736                validate_node_text("managed resume initial prompt", prompt)?;
16737            }
16738            let (record, session) = shared
16739                .resume_session_record(
16740                    &record_id,
16741                    terminal_size,
16742                    initial_prompt,
16743                )
16744                .await?;
16745            Ok(NodeResponse::SessionRecordResumed { record, session })
16746        }
16747        NodeRequest::ForgetSessionRecord { record_id } => {
16748            shared.require_controller(connection_id, role)?;
16749            shared.forget_session_record(&record_id).await?;
16750            Ok(NodeResponse::SessionRecordForgotten { record_id })
16751        }
16752        NodeRequest::Prompt { session, text } => {
16753            let agent_id = controlled_session(shared, connection_id, role, &session)?;
16754            validate_node_text("prompt", &text)?;
16755            shared.require_session_runtime_policy(
16756                &session,
16757                ProviderRuntimeRequirement::SemanticPrompt,
16758            )?;
16759            let framing = prompt_framing(&agent_id);
16760            shared
16761                .dispatch_input_bounded(
16762                    &session,
16763                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
16764                )
16765                .await?;
16766            Ok(NodeResponse::Accepted)
16767        }
16768        NodeRequest::Paste { session, text } => {
16769            controlled_session(shared, connection_id, role, &session)?;
16770            validate_node_text("paste", &text)?;
16771            shared.require_session_runtime_policy(
16772                &session,
16773                ProviderRuntimeRequirement::SemanticPrompt,
16774            )?;
16775            shared
16776                .dispatch_input_bounded(
16777                    &session,
16778                    InputAction::InsertDraft(PromptPayload {
16779                        text,
16780                        framing: PromptFraming::BracketedPaste,
16781                    }),
16782                )
16783                .await?;
16784            Ok(NodeResponse::Accepted)
16785        }
16786        NodeRequest::Input { session, text } => {
16787            controlled_session(shared, connection_id, role, &session)?;
16788            validate_node_text("terminal input", &text)?;
16789            shared
16790                .dispatch_input_bounded(
16791                    &session,
16792                    InputAction::TerminalText(TerminalText { text }),
16793                )
16794                .await?;
16795            Ok(NodeResponse::Accepted)
16796        }
16797        NodeRequest::TerminalBytes { session, bytes } => {
16798            controlled_session(shared, connection_id, role, &session)?;
16799            shared.sessionize_spawn(session.session.instance_id);
16800            let action = terminal_bytes_action(bytes)?;
16801            shared
16802                .dispatch_input_bounded(&session, action)
16803                .await?;
16804            Ok(NodeResponse::Accepted)
16805        }
16806        NodeRequest::TerminalControl { session, control } => {
16807            controlled_session(shared, connection_id, role, &session)?;
16808            shared
16809                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16810                .await?;
16811            Ok(NodeResponse::Accepted)
16812        }
16813        NodeRequest::Resize { session, size } => {
16814            controlled_session(shared, connection_id, role, &session)?;
16815            shared.sessionize_spawn(session.session.instance_id);
16816            if !size.is_valid() {
16817                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16818            }
16819            shared.dispatch_resize_bounded(&session, size).await?;
16820            Ok(NodeResponse::Accepted)
16821        }
16822        NodeRequest::Interrupt { session } => {
16823            controlled_session(shared, connection_id, role, &session)?;
16824            shared
16825                .dispatch_input_bounded(
16826                    &session,
16827                    InputAction::TerminalControl(TerminalControl::Interrupt),
16828                )
16829                .await?;
16830            Ok(NodeResponse::Accepted)
16831        }
16832        NodeRequest::Stop { session, force } => {
16833            controlled_session(shared, connection_id, role, &session)?;
16834            shared.sessionize_spawn(session.session.instance_id);
16835            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16836            shared.retire_session_grok_leader(session.session.instance_id);
16837            Ok(NodeResponse::Accepted)
16838        }
16839        NodeRequest::Remove { session } => {
16840            controlled_session(shared, connection_id, role, &session)?;
16841            shared.remove_session(&session).await?;
16842            Ok(NodeResponse::Accepted)
16843        }
16844        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16845            shared.require_controller(connection_id, role)?;
16846            let snapshot = shared.require_acp_session(&session)?;
16847            let interaction = snapshot
16848                .provider
16849                .interactions
16850                .iter()
16851                .find(|interaction| {
16852                    interaction_correlation(
16853                        session.session.instance_id,
16854                        session.session.generation,
16855                        interaction.id.0,
16856                    ) == correlation_id
16857                })
16858                .ok_or_else(|| failure(
16859                    NodeFailureCode::InvalidRequest,
16860                    "unknown interaction correlation",
16861                ))?;
16862            if interaction.status != ProviderInteractionStatus::Pending {
16863                return Err(failure(
16864                    NodeFailureCode::BackendOperationFailed,
16865                    "interaction has already been resolved",
16866                ));
16867            }
16868            response
16869                .validate_for(interaction.interaction_kind)
16870                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16871            let interaction_id = interaction.id;
16872            let response_kind = response.kind();
16873            let response_outcome = response.outcome();
16874            shared.dispatch(ControlCommand::ResolveInteraction {
16875                instance_id: session.session.instance_id,
16876                generation: session.session.generation,
16877                interaction_id,
16878                response,
16879            })?;
16880            // The one line that lets an operator compare what THIS node
16881            // recorded against what the provider itself reports doing --
16882            // measured live on a kimi run where our side resolved `true`
16883            // while the agent reported "rejected" and neither layer had a
16884            // line to line up against the other.
16885            tracing::info!(
16886                node_id = %shared.node_id,
16887                session = ?session.session,
16888                correlation_id = %correlation_id,
16889                interaction_id = interaction_id.0,
16890                response_kind = ?response_kind,
16891                outcome = ?response_outcome,
16892                "interaction resolved",
16893            );
16894            Ok(NodeResponse::Accepted)
16895        }
16896        NodeRequest::SetSessionMode { session, mode_id } => {
16897            shared.require_controller(connection_id, role)?;
16898            shared.require_acp_session(&session)?;
16899            shared.dispatch(ControlCommand::SetSessionMode {
16900                instance_id: session.session.instance_id,
16901                mode_id,
16902            })?;
16903            Ok(NodeResponse::Accepted)
16904        }
16905        NodeRequest::SetSessionConfigOption {
16906            session,
16907            option_id,
16908            value_json,
16909        } => {
16910            shared.require_controller(connection_id, role)?;
16911            shared.require_acp_session(&session)?;
16912            shared.dispatch(ControlCommand::SetSessionConfigOption {
16913                instance_id: session.session.instance_id,
16914                option_id,
16915                value_json,
16916            })?;
16917            Ok(NodeResponse::Accepted)
16918        }
16919        NodeRequest::SetSessionModel { session, model_id } => {
16920            shared.require_controller(connection_id, role)?;
16921            shared.require_acp_session(&session)?;
16922            shared.dispatch(ControlCommand::SetSessionModel {
16923                instance_id: session.session.instance_id,
16924                model_id,
16925            })?;
16926            Ok(NodeResponse::Accepted)
16927        }
16928        NodeRequest::Shutdown => {
16929            shared.require_controller(connection_id, role)?;
16930            shared.begin_shutdown_locked().await?;
16931            Ok(NodeResponse::ShuttingDown)
16932        }
16933    }
16934}
16935
16936fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16937    shared.require_controller(connection_id, role)?;
16938    shared.validate_address(session)
16939}
16940
16941/// Selects the sole history candidate matching `provider_session_id` when
16942/// the managed record carries a verified identity, or the sole candidate in
16943/// the discovered set at all when it does not (see
16944/// `export_context_pack_for_session_record_inner`'s identity match). Either
16945/// way, more than one match refuses instead of guessing which one is exact.
16946fn select_history_candidate(
16947    candidates: &[HistoryCandidateSummary],
16948    provider_session_id: Option<&str>,
16949) -> Result<String, NodeFailure> {
16950    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16951        Some(id) => candidate.session_id_hint == id,
16952        None => true,
16953    });
16954    let candidate = matches.next().ok_or_else(|| {
16955        failure(
16956            NodeFailureCode::SessionRecordConflict,
16957            if provider_session_id.is_some() {
16958                "no history candidate matches the managed provider session"
16959            } else {
16960                "no history candidate exists for the managed session"
16961            },
16962        )
16963    })?;
16964    if matches.next().is_some() {
16965        return Err(failure(
16966            NodeFailureCode::SessionRecordConflict,
16967            if provider_session_id.is_some() {
16968                "history identity is ambiguous for the managed provider session"
16969            } else {
16970                "history is ambiguous without a verified provider session identity"
16971            },
16972        ));
16973    }
16974    Ok(candidate.id.clone())
16975}
16976
16977fn resolved_spawn_profile_summary(
16978    profile: &SpawnProfileDefaults,
16979    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16980) -> Option<SpawnProfileSummary> {
16981    let environment_profile = match profile.environment_profile_id.as_ref() {
16982        None => None,
16983        Some(profile_id) => {
16984            let binding = environment_profiles.get(profile_id)?;
16985            if binding.provider != profile.provider
16986                || binding.native_profile_id(profile.mode).is_none()
16987            {
16988                return None;
16989            }
16990            Some(ResolvedEnvironmentProfileReceipt {
16991                profile_id: binding.id.clone(),
16992                profile_revision: binding.revision.clone(),
16993                network_allowlist: None,
16994                browser_profile_id: None,
16995            })
16996        }
16997    };
16998    Some(SpawnProfileSummary {
16999        id: profile.profile_id.clone(),
17000        revision: profile.revision.clone(),
17001        environment_profile,
17002    })
17003}
17004
17005/// True if `current` is an acceptable version of `expected` at export
17006/// revalidation/commit time.
17007///
17008/// When `allow_clean_detachment` is `false` (the on-demand,
17009/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
17010/// be byte-identical to `expected` and still actively bound to `session` —
17011/// the original, unchanged invariant.
17012///
17013/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
17014/// ALSO be the exact Live -> Dormant transition that the same clean exit this
17015/// export reacts to produces on its own: `state` Live -> Dormant,
17016/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
17017/// advancing. Every other field — including `provider_session`, `context`,
17018/// `bundle`, `task_binding` — must still match exactly. A record that changed
17019/// in any other way (rebind, a different active session, an identity
17020/// conflict, capacity eviction) is rejected either way; this widens nothing
17021/// about which sessions are eligible, only how the record's own expected
17022/// detachment is tolerated mid-export.
17023fn session_record_export_target_matches(
17024    expected: &ManagedSessionRecord,
17025    current: &ManagedSessionRecord,
17026    session: &SessionAddress,
17027    allow_clean_detachment: bool,
17028) -> bool {
17029    if current == expected && current.active_session.as_ref() == Some(session) {
17030        return current.state == ManagedSessionState::Live;
17031    }
17032    allow_clean_detachment
17033        && current.state == ManagedSessionState::Dormant
17034        && current.active_session.is_none()
17035        && current.record_id == expected.record_id
17036        && current.display_name == expected.display_name
17037        && current.provider == expected.provider
17038        && current.mode == expected.mode
17039        && current.workspace_id == expected.workspace_id
17040        && current.canonical_root == expected.canonical_root
17041        && current.provider_session == expected.provider_session
17042        && current.environment_profile == expected.environment_profile
17043        && current.bundle == expected.bundle
17044        && current.context_id == expected.context_id
17045        && current.context == expected.context
17046        && current.exported_context == expected.exported_context
17047        && current.task_binding == expected.task_binding
17048        && current.created_at_unix_ms == expected.created_at_unix_ms
17049        && current.last_error == expected.last_error
17050}
17051
17052fn session_record_context_export_binding_is_exact(
17053    expected: &ManagedSessionRecord,
17054    current: &ManagedSessionRecord,
17055    identity: Option<&ProviderSessionIdentity>,
17056    session: &SessionAddress,
17057    binding: &SessionBinding,
17058    provider: &AgentId,
17059    current_root: &str,
17060    allow_clean_detachment: bool,
17061) -> bool {
17062    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17063        && current.provider_session.as_ref() == identity
17064        && current.workspace_id == session.workspace_id
17065        && provider == &current.provider
17066        && binding.workspace_id == session.workspace_id
17067        && binding.generation == session.session.generation
17068        && binding.record_id.as_ref() == Some(&current.record_id)
17069        && platform::roots_equal(
17070            current_root,
17071            windows_path_text(&current.canonical_root),
17072        )
17073}
17074
17075fn session_record_context_export_source_is_usable(
17076    record: &ManagedSessionRecord,
17077    status: &SessionStatus,
17078    allow_clean_detachment: bool,
17079) -> bool {
17080    (record.state == ManagedSessionState::Live
17081        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17082        && context_pack_source_status_is_usable(status)
17083}
17084
17085fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17086    PromptFraming::BracketedPaste
17087}
17088
17089fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17090    if text.len() > MAX_NODE_TEXT_BYTES {
17091        return Err(failure(
17092            NodeFailureCode::InvalidRequest,
17093            &format!(
17094                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17095                text.len(),
17096            ),
17097        ));
17098    }
17099    Ok(())
17100}
17101
17102fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17103    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17104        return Err(failure(
17105            NodeFailureCode::InvalidRequest,
17106            &format!(
17107                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17108                bytes.len(),
17109            ),
17110        ));
17111    }
17112    Ok(())
17113}
17114
17115fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17116    validate_terminal_bytes(&bytes)?;
17117    Ok(InputAction::TerminalBytes(bytes))
17118}
17119
17120fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17121    if root.is_empty()
17122        || root.len() > MAX_WORKSPACE_ROOT_BYTES
17123        || root.chars().any(char::is_control)
17124    {
17125        return Err(failure(
17126            NodeFailureCode::InvalidWorkspaceRoot,
17127            &format!(
17128                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17129            ),
17130        ));
17131    }
17132    Ok(())
17133}
17134
17135fn canonical_standalone_workspace_candidate(
17136    workspace_id: &WorkspaceId,
17137    root: &str,
17138) -> Result<String, NodeServerError> {
17139    let path = Path::new(root);
17140    match std::fs::symlink_metadata(path) {
17141        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17142            .map(|workspace| workspace.canonical_root),
17143        Err(error) if error.kind() == io::ErrorKind::NotFound => {
17144            if !path.is_absolute() {
17145                return Err(NodeServerError::InvalidWorkspaceRoot {
17146                    workspace_id: workspace_id.clone(),
17147                    path: root.to_owned(),
17148                    message: "path is not absolute".to_owned(),
17149                });
17150            }
17151            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17152                workspace_id: workspace_id.clone(),
17153                path: root.to_owned(),
17154                message: "path has no standalone directory name".to_owned(),
17155            })?;
17156            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17157                workspace_id: workspace_id.clone(),
17158                path: root.to_owned(),
17159                message: "path has no parent directory".to_owned(),
17160            })?;
17161            let canonical = std::fs::canonicalize(parent)
17162                .map(|parent| parent.join(name))
17163                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17164                    workspace_id: workspace_id.clone(),
17165                    path: root.to_owned(),
17166                    message: error.to_string(),
17167                })?;
17168            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17169                NodeServerError::InvalidWorkspaceRoot {
17170                    workspace_id: workspace_id.clone(),
17171                    path: path.to_string_lossy().into_owned(),
17172                    message: "canonical path is not valid Unicode".to_owned(),
17173                }
17174            })?;
17175            let canonical_root = platform::normalize_canonical_root(canonical_root);
17176            validate_workspace_root(workspace_id, &canonical_root)?;
17177            Ok(canonical_root)
17178        }
17179        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17180            workspace_id: workspace_id.clone(),
17181            path: root.to_owned(),
17182            message: error.to_string(),
17183        }),
17184    }
17185}
17186
17187fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17188    let Some(revision) = revision else {
17189        return Ok(());
17190    };
17191    if revision.is_empty()
17192        || revision.len() > MAX_REPOSITORY_PATH_BYTES
17193        || revision.chars().any(char::is_control)
17194    {
17195        return Err(failure(
17196            NodeFailureCode::InvalidRequest,
17197            &format!(
17198                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17199            ),
17200        ));
17201    }
17202    Ok(())
17203}
17204
17205fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17206    let code = match error.kind {
17207        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17208        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17209        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17210        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17211        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17212        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17213        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17214    };
17215    failure(code, &error.message)
17216}
17217
17218fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17219    let code = match error.kind {
17220        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17221        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17222        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17223            NodeFailureCode::BackendOperationFailed
17224        }
17225        StandaloneWorkspaceErrorKind::RecoveryRequired => {
17226            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17227        }
17228    };
17229    failure(code, error.message)
17230}
17231
17232fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17233    let (code, message) = match error.kind {
17234        GitWorktreeErrorKind::Invalid => (
17235            NodeFailureCode::InvalidRequest,
17236            "managed Git input is invalid",
17237        ),
17238        GitWorktreeErrorKind::NotRepository => (
17239            NodeFailureCode::NotGitRepository,
17240            "managed source is not a Git repository",
17241        ),
17242        GitWorktreeErrorKind::Conflict => (
17243            NodeFailureCode::WorktreeConflict,
17244            "managed Git identity conflicts with existing repository state",
17245        ),
17246        GitWorktreeErrorKind::Protected => (
17247            NodeFailureCode::ManagedWorktreeOwnershipConflict,
17248            "managed worktree ownership validation failed",
17249        ),
17250        GitWorktreeErrorKind::Dirty => (
17251            NodeFailureCode::WorktreeDirty,
17252            "managed worktree contains uncommitted changes",
17253        ),
17254        GitWorktreeErrorKind::Locked => (
17255            NodeFailureCode::WorktreeLocked,
17256            "managed worktree is locked",
17257        ),
17258        GitWorktreeErrorKind::Failed => (
17259            NodeFailureCode::BackendOperationFailed,
17260            "managed Git operation failed",
17261        ),
17262    };
17263    NodeFailure { code, message: message.to_owned() }
17264}
17265
17266fn managed_spawn_receipt(
17267    resolved: &ResolvedSpawnSpec,
17268    incarnation_id: NodeIncarnationId,
17269    session: SessionAddress,
17270    lease: ManagedWorktreeLeaseSnapshot,
17271    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17272    bundle: Option<ResolvedBundleReceipt>,
17273    context: Option<ResolvedContextPackReceipt>,
17274) -> ManagedWorktreeSpawnReceipt {
17275    let mut managed = resolved.clone();
17276    managed.target.worktree_id = Some(lease.workspace_id.clone());
17277    ManagedWorktreeSpawnReceipt {
17278        spawn: managed.receipt_with_materialization(
17279            incarnation_id,
17280            session,
17281            environment_profile,
17282            bundle,
17283            context,
17284        ),
17285        lease,
17286    }
17287}
17288
17289fn validate_selected_worktree(
17290    source_root: &str,
17291    selected_root: &str,
17292    worktrees: &[NativeGitWorktreeSnapshot],
17293) -> Result<(), NodeFailure> {
17294    if !worktrees
17295        .iter()
17296        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17297    {
17298        return Err(failure(
17299            NodeFailureCode::WorktreeProtected,
17300            "source workspace is not an authoritative non-bare Git worktree root",
17301        ));
17302    }
17303    if !worktrees.iter().any(|worktree| {
17304        worktree_paths_equal(&worktree.path, selected_root)
17305            && !worktree.is_main
17306            && !worktree.is_bare
17307    }) {
17308        return Err(failure(
17309            NodeFailureCode::WorktreeProtected,
17310            "selected workspace is not an eligible Git worktree in the source repository",
17311        ));
17312    }
17313    Ok(())
17314}
17315
17316fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17317    let code = match error.kind() {
17318        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17319        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17320            NodeFailureCode::RepositoryPathUnsafe
17321        }
17322        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17323        WorkspaceFileReadErrorKind::NotRegularFile => {
17324            NodeFailureCode::RepositoryFileNotRegular
17325        }
17326        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17327            NodeFailureCode::RepositoryFileReadFailed
17328        }
17329        WorkspaceFileReadErrorKind::RevisionConflict => {
17330            NodeFailureCode::RepositoryFileRevisionConflict
17331        }
17332        WorkspaceFileReadErrorKind::AlreadyExists
17333        | WorkspaceFileReadErrorKind::ParentNotFound
17334        | WorkspaceFileReadErrorKind::ParentNotDirectory
17335        | WorkspaceFileReadErrorKind::Canceled => {
17336            NodeFailureCode::RepositoryFileReadFailed
17337        }
17338    };
17339    failure(code, "repository file read failed")
17340}
17341
17342fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17343    let code = match error.kind() {
17344        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17345        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17346            NodeFailureCode::RepositoryPathUnsafe
17347        }
17348        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17349        WorkspaceFileReadErrorKind::NotRegularFile => {
17350            NodeFailureCode::RepositoryFileNotRegular
17351        }
17352        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17353            NodeFailureCode::RepositoryFileWriteFailed
17354        }
17355        WorkspaceFileReadErrorKind::RevisionConflict => {
17356            NodeFailureCode::RepositoryFileRevisionConflict
17357        }
17358        WorkspaceFileReadErrorKind::AlreadyExists
17359        | WorkspaceFileReadErrorKind::ParentNotFound
17360        | WorkspaceFileReadErrorKind::ParentNotDirectory
17361        | WorkspaceFileReadErrorKind::Canceled => {
17362            NodeFailureCode::RepositoryFileWriteFailed
17363        }
17364    };
17365    failure(code, "repository file write failed")
17366}
17367
17368async fn settle_workspace_entry_create<T>(
17369    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17370    commit_state: Arc<AtomicU8>,
17371    deadline: Duration,
17372    timeout_message: &'static str,
17373    task_failure_message: &'static str,
17374) -> Result<T, NodeFailure> {
17375    let joined = match timeout(deadline, &mut task).await {
17376        Ok(joined) => joined,
17377        Err(_) => {
17378            if commit_state
17379                .compare_exchange(
17380                    WORKSPACE_ENTRY_CREATE_PENDING,
17381                    WORKSPACE_ENTRY_CREATE_CANCELED,
17382                    Ordering::AcqRel,
17383                    Ordering::Acquire,
17384                )
17385                .is_ok()
17386            {
17387                return Err(failure(
17388                    NodeFailureCode::RepositoryEntryCreateTimedOut,
17389                    timeout_message,
17390                ));
17391            }
17392            debug_assert_eq!(
17393                commit_state.load(Ordering::Acquire),
17394                WORKSPACE_ENTRY_CREATE_COMMITTING,
17395            );
17396            task.await
17397        }
17398    };
17399    joined
17400        .map_err(|_| {
17401            failure(
17402                NodeFailureCode::RepositoryEntryCreateFailed,
17403                task_failure_message,
17404            )
17405        })?
17406        .map_err(workspace_entry_create_failure)
17407}
17408
17409fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17410    let code = match error.kind() {
17411        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17412        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17413            NodeFailureCode::RepositoryPathUnsafe
17414        }
17415        WorkspaceFileReadErrorKind::AlreadyExists => {
17416            NodeFailureCode::RepositoryEntryAlreadyExists
17417        }
17418        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17419            NodeFailureCode::RepositoryParentNotFound
17420        }
17421        WorkspaceFileReadErrorKind::ParentNotDirectory
17422        | WorkspaceFileReadErrorKind::NotRegularFile => {
17423            NodeFailureCode::RepositoryParentNotDirectory
17424        }
17425        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17426            NodeFailureCode::RepositoryEntryCreateFailed
17427        }
17428        WorkspaceFileReadErrorKind::RevisionConflict => {
17429            NodeFailureCode::RepositoryEntryCreateFailed
17430        }
17431        WorkspaceFileReadErrorKind::Canceled => {
17432            NodeFailureCode::RepositoryEntryCreateTimedOut
17433        }
17434    };
17435    failure(code, "repository entry create failed")
17436}
17437
17438fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17439    let value = digest(&SHA256, bytes)
17440        .as_ref()
17441        .iter()
17442        .map(|byte| format!("{byte:02x}"))
17443        .collect();
17444    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17445}
17446
17447fn managed_spawn_request_digest_v2(
17448    request: &ManagedWorktreeSpawnRequestV2,
17449) -> Result<String, NodeFailure> {
17450    let encoded = serde_json::to_vec(request).map_err(|_| {
17451        failure(
17452            NodeFailureCode::InvalidRequest,
17453            "managed worktree V2 request could not be canonicalized",
17454        )
17455    })?;
17456    let mut value = String::with_capacity(71);
17457    value.push_str("sha256:");
17458    for byte in digest(&SHA256, &encoded).as_ref() {
17459        use std::fmt::Write as _;
17460        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17461    }
17462    Ok(value)
17463}
17464
17465fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17466    let code = match error {
17467        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17468        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17469        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17470        NativeSessionPreviewError::UnsupportedProvider
17471        | NativeSessionPreviewError::PreviewUnavailable
17472        | NativeSessionPreviewError::SessionNotFound
17473        | NativeSessionPreviewError::AmbiguousSession => {
17474            NodeFailureCode::BackendOperationFailed
17475        }
17476    };
17477    failure(code, "native session preview failed")
17478}
17479
17480fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17481    let code = match error.kind() {
17482        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17483        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17484    };
17485    failure(code, "host directory browse failed")
17486}
17487
17488fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17489    let code = match error {
17490        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17491        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17492        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17493            NodeFailureCode::DeliveryStageConflict
17494        }
17495        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17496        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17497            NodeFailureCode::DeliveryChunkOutOfOrder
17498        }
17499        DeliveryStoreError::BlobDigestMismatch => {
17500            NodeFailureCode::DeliveryBlobDigestMismatch
17501        }
17502        DeliveryStoreError::BundleDigestMismatch => {
17503            NodeFailureCode::DeliveryBundleDigestMismatch
17504        }
17505        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17506        DeliveryStoreError::Unavailable
17507        | DeliveryStoreError::Corrupt
17508        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17509    };
17510    failure(code, "delivery operation failed")
17511}
17512
17513fn validate_workspace_root(
17514    workspace_id: &WorkspaceId,
17515    canonical_root: &str,
17516) -> Result<(), NodeServerError> {
17517    #[cfg(windows)]
17518    if canonical_root.starts_with(r"\\") {
17519        return Err(NodeServerError::InvalidWorkspaceRoot {
17520            workspace_id: workspace_id.clone(),
17521            path: canonical_root.to_owned(),
17522            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17523        });
17524    }
17525    if !platform::workspace_root_supported(canonical_root)
17526        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17527    {
17528        return Err(NodeServerError::InvalidWorkspaceRoot {
17529            workspace_id: workspace_id.clone(),
17530            path: canonical_root.to_owned(),
17531            message: "canonical path must be absolute, bounded, and free of control characters"
17532                .to_owned(),
17533        });
17534    }
17535    Ok(())
17536}
17537
17538fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17539    NodeFailure {
17540        code,
17541        message: node_failure_category(code).to_owned(),
17542    }
17543}
17544
17545fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17546    let code = match error {
17547        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17548        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17549        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17550        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17551        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17552        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17553        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17554        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17555        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17556    };
17557    failure(code, "harness MCP proxy operation failed")
17558}
17559
17560/// Numbers each harness-MCP overlay this process constructs, in
17561/// construction order, so two overlays built back to back in one node process
17562/// still get distinct trace files.
17563static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17564
17565/// Builds the optional trace environment entry of one harness-MCP overlay.
17566/// The caller's launch description names both the entry and the node-process
17567/// variable that opts in: when that variable holds a non-empty directory, the
17568/// entry names a trace file under it. Returns `None` when the launch has no
17569/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
17570/// the directory; that is the operator's job.
17571fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17572    let trace = launch.trace.as_ref()?;
17573    let trace_dir = std::env::var(&trace.dir_env).ok()?;
17574    if trace_dir.is_empty() {
17575        return None;
17576    }
17577    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17578    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17579    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17580}
17581
17582fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17583    let remaining = deadline.saturating_duration_since(Instant::now());
17584    if remaining.is_zero() {
17585        Err(failure(
17586            NodeFailureCode::SpawnDeadlineExceeded,
17587            "spawn deadline elapsed",
17588        ))
17589    } else {
17590        Ok(remaining)
17591    }
17592}
17593
17594fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17595    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17596    match deadline {
17597        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17598        None => Ok(bounded),
17599    }
17600}
17601
17602fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17603    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17604        failure(
17605            NodeFailureCode::SpawnDeadlineExceeded,
17606            "spawn dispatch exceeded the spawn deadline",
17607        )
17608    } else {
17609        error
17610    }
17611}
17612
17613fn spawn_runtime_capabilities(
17614    capabilities: &SpawnRequiredCapabilities,
17615) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17616    capabilities
17617        .iter()
17618        .map(|capability| match capability.as_str() {
17619            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17620            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17621            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17622            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17623                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17624            }
17625            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17626            _ => Err(failure(
17627                NodeFailureCode::UnsupportedSpawnCapability,
17628                "spawn required capability is unknown",
17629            )),
17630        })
17631        .collect()
17632}
17633
17634fn persistence_failure(_error: io::Error) -> NodeFailure {
17635    NodeFailure {
17636        code: NodeFailureCode::BackendOperationFailed,
17637        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17638    }
17639}
17640
17641fn node_failure_category(code: NodeFailureCode) -> &'static str {
17642    match code {
17643        NodeFailureCode::InvalidRequest => "invalid-request",
17644        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17645        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17646        NodeFailureCode::ReservationNotFound => "reservation-not-found",
17647        NodeFailureCode::ReservationConflict => "reservation-conflict",
17648        NodeFailureCode::ReservationExpired => "reservation-expired",
17649        NodeFailureCode::BindingMismatch => "binding-mismatch",
17650        NodeFailureCode::NotActivated => "not-activated",
17651        NodeFailureCode::CallNotFound => "call-not-found",
17652        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17653        NodeFailureCode::ResponseTooLarge => "response-too-large",
17654        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17655        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17656        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17657        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17658        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17659        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17660        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17661        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17662        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17663        NodeFailureCode::Unauthorized => "unauthorized",
17664        NodeFailureCode::ObserverReadOnly => "observer-read-only",
17665        NodeFailureCode::ControllerBusy => "controller-busy",
17666        NodeFailureCode::ControllerRequired => "controller-required",
17667        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17668        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17669        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17670        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17671        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17672        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17673        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17674        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17675        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17676        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17677        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17678        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17679        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17680        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17681        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17682        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17683        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17684        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17685        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17686        NodeFailureCode::GitReadFailed => "git-read-failed",
17687        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17688        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17689        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17690        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17691        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17692        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17693        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17694        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17695        NodeFailureCode::TurnInFlight => "turn-in-flight",
17696        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17697        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17698        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17699            "unsupported-network-allowlist-mapping"
17700        }
17701        NodeFailureCode::BrowserStationProbeUnavailable => {
17702            "browser-station-probe-unavailable"
17703        }
17704        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17705        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17706        NodeFailureCode::EnvironmentProfileBindingMismatch => {
17707            "environment-profile-binding-mismatch"
17708        }
17709        NodeFailureCode::UnknownBundle => "unknown-bundle",
17710        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17711        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17712        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17713        NodeFailureCode::ContextPackBusy => "context-pack-busy",
17714        NodeFailureCode::ContextPackMaterializationFailed => {
17715            "context-pack-materialization-failed"
17716        }
17717        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17718        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17719        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17720        NodeFailureCode::WorkspaceBusy => "workspace-busy",
17721        NodeFailureCode::LastWorkspace => "last-workspace",
17722        NodeFailureCode::NotGitRepository => "not-git-repository",
17723        NodeFailureCode::WorktreeConflict => "worktree-conflict",
17724        NodeFailureCode::WorktreeProtected => "worktree-protected",
17725        NodeFailureCode::WorktreeDirty => "worktree-dirty",
17726        NodeFailureCode::WorktreeLocked => "worktree-locked",
17727        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17728        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17729        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17730        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17731            "managed-worktree-profile-revision-mismatch"
17732        }
17733        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17734        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17735            "standalone-workspace-recovery-required"
17736        }
17737        NodeFailureCode::UnknownSession => "unknown-session",
17738        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17739        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17740        NodeFailureCode::SessionRecordBusy => "session-record-busy",
17741        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17742        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17743        NodeFailureCode::WorkspaceRegistrationRequired => {
17744            "workspace-registration-required"
17745        }
17746        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17747        NodeFailureCode::StaleGeneration => "stale-generation",
17748        NodeFailureCode::BackendBusy => "backend-busy",
17749        NodeFailureCode::BackendDisconnected => "backend-disconnected",
17750        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17751        NodeFailureCode::ShuttingDown => "shutting-down",
17752    }
17753}
17754
17755fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17756    NodeServerError::DurableState(io::Error::new(error.kind(), category))
17757}
17758
17759fn durable_state_load_error(error: io::Error) -> NodeServerError {
17760    let category = match session_registry::state_load_refusal(&error) {
17761        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17762            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17763        }
17764        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17765            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17766        }
17767        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17768            DURABLE_STATE_CONFLICT_ERROR
17769        }
17770        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17771    };
17772    durable_state_server_error(error, category)
17773}
17774
17775fn unix_time_ms() -> u64 {
17776    std::time::SystemTime::now()
17777        .duration_since(std::time::UNIX_EPOCH)
17778        .unwrap_or_default()
17779        .as_millis()
17780        .min(u64::MAX as u128) as u64
17781}
17782
17783fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17784    if record.provider_session.is_some() {
17785        ManagedSessionState::Dormant
17786    } else {
17787        ManagedSessionState::Unavailable
17788    }
17789}
17790
17791fn active_record_state(
17792    identity_admitted: bool,
17793    provider_session_is_known: bool,
17794) -> ManagedSessionState {
17795    if identity_admitted && !provider_session_is_known {
17796        ManagedSessionState::IdentityPending
17797    } else {
17798        ManagedSessionState::Live
17799    }
17800}
17801
17802fn managed_worktree_record_holders(
17803    records: &[ManagedSessionRecord],
17804) -> Vec<(SessionRecordId, WorkspaceId)> {
17805    records
17806        .iter()
17807        .filter(|record| record.provider_session.is_some())
17808        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17809        .collect()
17810}
17811
17812fn managed_spawn_record_ownership(
17813    binding: &SessionBinding,
17814    effective_runtime_policy: ProviderRuntimePolicy,
17815) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17816    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17817    if effective_runtime_policy.provider_session_identity {
17818        (binding.record_id.clone(), None)
17819    } else {
17820        (None, binding.record_id.clone())
17821    }
17822}
17823
17824#[derive(Debug, Error)]
17825pub enum NodeServerError {
17826    #[error("node endpoint must be a bounded absolute local transport path")]
17827    InvalidEndpoint,
17828    #[error("node access token must contain 1..=4096 bytes")]
17829    InvalidAccessToken,
17830    #[error("node HTTP observer must listen on a loopback address: {0}")]
17831    InvalidApiListen(std::net::SocketAddr),
17832    #[error("node browser bridge must listen on a loopback address: {0}")]
17833    InvalidBridgeListen(std::net::SocketAddr),
17834    #[error("node browser bridge token must contain 1..=4096 bytes")]
17835    InvalidBridgeToken,
17836    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17837    BridgeUnderlayRequiresBridgeListen,
17838    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17839    BridgeUnderlayRequiresBridgeToken,
17840    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17841    InvalidBridgeUnderlayToken,
17842    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17843    InvalidBridgeUnderlayKey,
17844    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17845    InvalidCallHome(std::net::SocketAddr),
17846    #[error("node could not reach its call-home relay: {0}")]
17847    CallHomeTransport(#[source] std::io::Error),
17848    #[error("node durable state path must be an absolute file path: {0}")]
17849    InvalidStatePath(String),
17850    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17851    InvalidHarnessMcpHelper,
17852    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17853    InvalidNetworkAllowlistCatalogPath(String),
17854    #[error("network allowlist catalog could not be read: {0}")]
17855    NetworkAllowlistCatalogIo(#[source] std::io::Error),
17856    #[error("network allowlist catalog line {line}: {message}")]
17857    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17858    #[error("network allowlist catalog: {0}")]
17859    InvalidNetworkAllowlistCatalog(String),
17860    #[error("network allowlist catalog exceeds the {max}-entry limit")]
17861    NetworkAllowlistCatalogCapacity { max: usize },
17862    #[error("the local state directory is unavailable; supply an explicit state path")]
17863    LocalStateDirectoryUnavailable,
17864    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17865    LocalRuntimeDirectoryUnavailable,
17866    #[error("node durable state failed: {0}")]
17867    DurableState(io::Error),
17868    #[error("node requires at least one configured workspace")]
17869    NoWorkspaces,
17870    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17871    InvalidWorkspaceRoot {
17872        workspace_id: WorkspaceId,
17873        path: String,
17874        message: String,
17875    },
17876    #[error("duplicate workspace ID: {0}")]
17877    DuplicateWorkspaceId(WorkspaceId),
17878    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17879    DuplicateWorkspaceRoot {
17880        first: WorkspaceId,
17881        second: WorkspaceId,
17882        root: String,
17883    },
17884    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17885    ManagedWorktreeProfileRequired(WorkspaceId),
17886    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17887    ManagedWorktreeProfileModeMismatch(WorkspaceId),
17888    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17889    DuplicateManagedWorktreeProfile {
17890        workspace_id: WorkspaceId,
17891        profile_id: WorktreeProfileId,
17892    },
17893    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17894    ManagedWorktreeProfileCapacity {
17895        workspace_id: WorkspaceId,
17896        max: usize,
17897    },
17898    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17899    InvalidManagedWorktreeProfile {
17900        workspace_id: WorkspaceId,
17901        profile_id: WorktreeProfileId,
17902        message: String,
17903    },
17904    #[error("duplicate node environment profile: {0}")]
17905    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17906    #[error("node environment profile provider is unavailable: {0}")]
17907    EnvironmentProfileProviderUnavailable(AgentId),
17908    #[error("node environment profile capacity is {max}")]
17909    EnvironmentProfileCapacity { max: usize },
17910    #[error("node environment profiles reuse native profile ID '{0}'")]
17911    DuplicateNativeEnvironmentProfile(String),
17912    #[error("node native environment profile is invalid: {0}")]
17913    NativeEnvironmentProfile(String),
17914    #[error("node session-environment materialization root must be absolute")]
17915    InvalidSessionEnvironmentRoot,
17916    #[error("node session-environment materializer is unavailable")]
17917    SessionEnvironmentMaterializerRequired,
17918    #[error("node session-environment materializer failed to initialize")]
17919    SessionEnvironmentMaterializer,
17920    #[error("node bundle catalog is invalid: {0}")]
17921    BundleCatalog(String),
17922    #[error("node delivery store failed to initialize: {0}")]
17923    DeliveryStore(String),
17924    #[error("node context pack store failed to initialize")]
17925    ContextPackStore,
17926    #[error("active agent registry failed: {0}")]
17927    Registry(String),
17928    #[error("node provider contract manifest is invalid: {0}")]
17929    ProviderContractManifest(String),
17930    #[error("named pipe I/O failed: {0}")]
17931    Io(#[from] io::Error),
17932    #[error("node HTTP observer failed: {0}")]
17933    HttpApi(io::Error),
17934    #[error("node browser bridge failed: {0}")]
17935    BridgeApi(io::Error),
17936    #[error(transparent)]
17937    Frame(#[from] FrameError),
17938    #[error("node handshake failed: {0}")]
17939    Handshake(String),
17940    #[error("node authentication frame was not received before the bounded deadline")]
17941    AuthenticationTimedOut,
17942    #[error("node authentication primitive failed: {0}")]
17943    Authentication(String),
17944    #[error("node incarnation identity generation failed: {0}")]
17945    IncarnationIdentity(String),
17946    #[error("node pre-authentication limiter is closed")]
17947    PreauthClosed,
17948    #[error("node authenticated connection limit was reached")]
17949    AuthenticatedConnectionLimit,
17950    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17951    SignalStreamClosed,
17952    #[error("node stop-all dispatch failed: {0:?}")]
17953    ShutdownDispatch(NodeFailure),
17954    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17955    ShutdownTimedOut { active_native_sessions: usize },
17956}
17957
17958impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17959    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17960        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17961        match error {
17962            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17963            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17964            E::InvalidEntry { line, message } => {
17965                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17966            }
17967            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17968            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17969        }
17970    }
17971}
17972
17973#[cfg(test)]
17974mod agent_stream_blocked_tests {
17975    use super::*;
17976    use gate4agent_types::{
17977        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17978        ProviderSource,
17979    };
17980
17981    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17982        ProviderSource {
17983            family,
17984            binding: AdapterBinding::new(
17985                AdapterId::new(adapter).unwrap(),
17986                "fixture/v1",
17987                AdapterVerification::SyntheticFixture,
17988            )
17989            .unwrap(),
17990        }
17991    }
17992
17993    fn provider_control_event_at(
17994        family: AdapterFamily,
17995        adapter: &str,
17996        source_sequence: u64,
17997        provider_event: ProviderEvent,
17998    ) -> ControlEvent {
17999        ControlEvent {
18000            sequence: 41,
18001            command_id: None,
18002            instance_id: AgentInstanceId(7),
18003            generation: SessionGeneration(3),
18004            event: ControlEventKind::ProviderEvent {
18005                sequence: 8,
18006                source: provider_source(family, adapter),
18007                source_sequence,
18008                event: provider_event,
18009            },
18010        }
18011    }
18012
18013    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18014        provider_control_event_at(family, "fixture", 9, provider_event)
18015    }
18016
18017    fn denied_host_request(
18018        by: ProviderHostDecisionAuthority,
18019        reason: Option<String>,
18020    ) -> ControlEvent {
18021        provider_control_event_at(
18022            AdapterFamily::Acp,
18023            "claude-code",
18024            2,
18025            ProviderEvent::HostRequestObserved {
18026                method: "terminal/create".to_owned(),
18027                params_json: String::new(),
18028                decision: ProviderHostRequestDecision::Denied { by },
18029                outcome: ProviderHostRequestOutcome::Executed,
18030                reason,
18031            },
18032        )
18033    }
18034
18035    fn blocked(
18036        event: &ControlEvent,
18037    ) -> (
18038        Option<String>,
18039        String,
18040        BlockAuthorityV1,
18041        Option<String>,
18042        String,
18043        Option<String>,
18044    ) {
18045        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18046        chunk.validate().expect("minted chunk must validate");
18047        let AgentStreamChunkKindV1::Blocked {
18048            correlation_id,
18049            tool_class,
18050            authority,
18051            reason_kind,
18052            reason,
18053            help,
18054        } = chunk.kind
18055        else {
18056            panic!("expected AgentStreamChunkKindV1::Blocked");
18057        };
18058        (correlation_id, tool_class, authority, reason_kind, reason, help)
18059    }
18060
18061    /// Pins the fleet and every adapter each member declares: the fleet is
18062    /// exactly what `active_registry` says, and no member declares a hook
18063    /// adapter (lifecycle hooks are retired).
18064    #[test]
18065    fn the_enabled_provider_matrix_is_exact() {
18066        let registry = active_registry().unwrap();
18067        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
18068        assert_eq!(
18069            providers
18070                .iter()
18071                .map(|contract| contract.provider.as_str())
18072                .collect::<Vec<_>>(),
18073            // `shell` is the plain PTY provider (operator terminals and smoke tests).
18074            vec!["shell", "claude", "codex", "grok", "kimi"],
18075        );
18076        assert!(adapters.iter().all(|contract| {
18077            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18078        }));
18079    }
18080
18081    /// A gate-denied host request, WITH its reason text carried all the way
18082    /// from the ACP host, mints a `Blocked` chunk naming the gate.
18083    #[test]
18084    fn gate_denied_host_request_mints_a_blocked_chunk() {
18085        let gate_text =
18086            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18087        let event = denied_host_request(
18088            ProviderHostDecisionAuthority::Gate,
18089            Some(gate_text.to_owned()),
18090        );
18091        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18092        assert_eq!(chunk.source_sequence, 2);
18093        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18094        assert_eq!(authority, BlockAuthorityV1::HostGate);
18095        assert_eq!(reason, gate_text);
18096        assert_eq!(correlation_id, None);
18097        assert_eq!(reason_kind, None);
18098        assert_eq!(help, None);
18099        assert_eq!(tool_class, "Write");
18100    }
18101
18102    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.";
18103
18104    /// The owner's classifier sample, verbatim, arriving as a
18105    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
18106    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
18107    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
18108    /// and the guidance tail as `help`.
18109    #[test]
18110    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18111        let event = provider_control_event(
18112            AdapterFamily::Acp,
18113            ProviderEvent::ToolCompleted {
18114                id: "toolu_9".to_owned(),
18115                output: CLASSIFIER_SAMPLE.to_owned(),
18116                is_error: true,
18117                duration_ms: None,
18118                agent_id: None,
18119                non_execution_kind: Some("permission-rule".to_owned()),
18120            },
18121        );
18122        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18123        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18124        assert_eq!(reason, "Blocked by classifier");
18125        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18126        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18127        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18128    }
18129
18130    /// A `session/prompt` RPC error with `data.codexErrorInfo:
18131    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
18132    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
18133    /// vendor code as `reason_kind`.
18134    #[test]
18135    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18136        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.";
18137        let event = provider_control_event(
18138            AdapterFamily::Acp,
18139            ProviderEvent::SessionEnded {
18140                result: "JSON-RPC error -32603: Internal error".to_owned(),
18141                cost_usd: None,
18142                is_error: true,
18143                stop_reason: Some(ProviderStopReason::ProviderError {
18144                    code: -32603,
18145                    message: message.to_owned(),
18146                    vendor_code: Some("usageLimitExceeded".to_owned()),
18147                }),
18148            },
18149        );
18150        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18151        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18152        assert_eq!(reason, message);
18153        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18154        assert_eq!(tool_class, "Session");
18155    }
18156
18157    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
18158    /// `authority: ProviderRefusal`.
18159    #[test]
18160    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18161        let event = provider_control_event(
18162            AdapterFamily::Acp,
18163            ProviderEvent::SessionEnded {
18164                result: "refusal".to_owned(),
18165                cost_usd: None,
18166                is_error: true,
18167                stop_reason: Some(ProviderStopReason::Refusal),
18168            },
18169        );
18170        let (_, _, authority, _, _, _) = blocked(&event);
18171        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18172    }
18173
18174    /// A failed tool completion with no typed `non_execution_kind` -- or one
18175    /// that only says the turn stopped -- is a plain execution failure, never a
18176    /// block; nor is an agent's own narrative claiming one.
18177    #[test]
18178    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18179        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18180            let event = provider_control_event(
18181                AdapterFamily::Acp,
18182                ProviderEvent::ToolCompleted {
18183                    id: "toolu_1".to_owned(),
18184                    output: "command exited with status 1".to_owned(),
18185                    is_error: true,
18186                    duration_ms: Some(12),
18187                    agent_id: None,
18188                    non_execution_kind,
18189                },
18190            );
18191            assert!(agent_stream_chunk(&event).is_none());
18192        }
18193        let narrative = provider_control_event(
18194            AdapterFamily::Acp,
18195            ProviderEvent::ToolCompleted {
18196                id: "toolu_2".to_owned(),
18197                output: "the harness blocked the write, approval needed".to_owned(),
18198                is_error: false,
18199                duration_ms: Some(3),
18200                agent_id: None,
18201                non_execution_kind: None,
18202            },
18203        );
18204        assert!(agent_stream_chunk(&narrative).is_none());
18205    }
18206
18207    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
18208    /// execution outcome: a granted request that failed while running was
18209    /// still authorized.
18210    #[test]
18211    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18212        for (decision, outcome) in [
18213            (
18214                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18215                ProviderHostRequestOutcome::Executed,
18216            ),
18217            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18218            (
18219                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18220                ProviderHostRequestOutcome::Failed {
18221                    error: "terminal/create spawn failed: os error 3".to_owned(),
18222                },
18223            ),
18224        ] {
18225            let event = provider_control_event_at(
18226                AdapterFamily::Acp,
18227                "claude-code",
18228                3,
18229                ProviderEvent::HostRequestObserved {
18230                    method: "fs/read_text_file".to_owned(),
18231                    params_json: String::new(),
18232                    decision: decision.clone(),
18233                    outcome,
18234                    reason: None,
18235                },
18236            );
18237            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18238        }
18239    }
18240
18241    /// A deferred `session/request_permission` resolved by an operator or an
18242    /// expired deadline carries no reason text -- the chunk falls back to a
18243    /// categorical sentence naming the authority rather than inventing detail.
18244    #[test]
18245    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18246        for (by, expected_authority, expected_reason) in [
18247            (
18248                ProviderHostDecisionAuthority::Operator,
18249                BlockAuthorityV1::Operator,
18250                "declined by the operator",
18251            ),
18252            (
18253                ProviderHostDecisionAuthority::DeadlinePolicy,
18254                BlockAuthorityV1::HostDeadline,
18255                "denied by host policy after the operator deadline expired",
18256            ),
18257        ] {
18258            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18259            assert_eq!(authority, expected_authority);
18260            assert_eq!(reason, expected_reason);
18261        }
18262    }
18263
18264    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
18265    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
18266    /// outright rather than truncating.
18267    #[test]
18268    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18269        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18270        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18271            ProviderHostDecisionAuthority::Policy,
18272            Some(oversized),
18273        ));
18274        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18275    }
18276}
18277
18278#[cfg(test)]
18279mod standalone_capability_platform_tests {
18280    use super::*;
18281
18282    #[test]
18283    fn standalone_workspace_capability_requires_windows_identity_guards() {
18284        let capabilities = baseline_capabilities().unwrap();
18285        let advertised = capabilities.iter().any(|capability| {
18286            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18287        });
18288        assert_eq!(advertised, cfg!(windows));
18289
18290        let request = NodeRequest::CreateStandaloneWorkspace {
18291            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18292            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18293            initial_branch: None,
18294        };
18295        assert_eq!(
18296            request_uses_unnegotiated_capability(&request, &capabilities),
18297            !cfg!(windows),
18298        );
18299    }
18300}
18301
18302
18303#[cfg(test)]
18304mod station_knobs_resolve_tests {
18305    use super::*;
18306    use gate4agent_catalog::EnvMutation;
18307    use gate4agent_runtime_native::{
18308        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18309        NativeLaunchProfileId,
18310    };
18311    use std::ffi::OsString;
18312    use std::sync::Arc;
18313
18314    struct EmptyEnvironmentResolver;
18315
18316    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18317        fn resolve_child_environment(
18318            &self,
18319        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18320            Ok(vec![EnvMutation {
18321                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18322                value: None,
18323            }])
18324        }
18325    }
18326
18327    fn agent(value: &str) -> AgentId {
18328        AgentId::new(value).unwrap()
18329    }
18330
18331    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18332        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18333        let profile_revision =
18334            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18335        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18336            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18337            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18338            provider: agent("claude"),
18339            mode: SessionMode::Pty,
18340            terminal_size: gate4agent_types::TerminalSize {
18341                rows: 24,
18342                columns: 80,
18343            },
18344            prompt: None,
18345            bundle_id: None,
18346            context_id: None,
18347            environment_profile_id: Some(profile_id.clone()),
18348        };
18349        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18350        let catalog = active_registry().unwrap();
18351        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18352        let control = runtime.native_launch_profile_control();
18353        let node_profile = NodeEnvironmentProfile::new(
18354            profile_id.clone(),
18355            profile_revision,
18356            agent("claude"),
18357            [NativeLaunchProfile::new(
18358                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18359                agent("claude"),
18360                TransportKind::Pty,
18361                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18362                Arc::new(EmptyEnvironmentResolver),
18363            )
18364            .unwrap()],
18365        )
18366        .unwrap();
18367        let (binding, native_profiles, _materialization) = node_profile.into_parts();
18368        for native_profile in native_profiles {
18369            runtime.upsert_native_launch_profile(native_profile).unwrap();
18370        }
18371        let workspace = WorkspaceConfig::new(
18372            WorkspaceId::new("primary").unwrap(),
18373            std::env::current_dir().unwrap(),
18374        )
18375        .unwrap();
18376        let shared = NodeShared::new_with_incarnation(
18377            handle,
18378            "fixture-token".to_owned(),
18379            NodeId::new("node-station-knobs-test").unwrap(),
18380            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18381            vec![workspace],
18382            vec![agent("claude")],
18383            ProviderRuntimeStatuses::default(),
18384            None,
18385            Vec::new(),
18386            Vec::new(),
18387            spawn_profiles,
18388            Some(control),
18389            None,
18390            None,
18391            None,
18392            Vec::new(),
18393            Vec::new(),
18394            Vec::new(),
18395            Vec::new(),
18396            None,
18397            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18398        );
18399        shared
18400            .environment_profiles
18401            .write()
18402            .unwrap_or_else(|poisoned| poisoned.into_inner())
18403            .insert(binding.id.clone(), binding);
18404        (shared, spawn_defaults)
18405    }
18406
18407    /// Empty-default catalog refuses unknown network_allowlist ids.
18408    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
18409    #[test]
18410    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18411        let (shared, defaults) = station_knobs_fixture();
18412        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18413        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18414        let spec = crate::protocol::SpawnSpec {
18415            target: crate::protocol::SpawnTarget {
18416                node_id: shared.node_id.clone(),
18417                workspace_id: WorkspaceId::new("primary").unwrap(),
18418                worktree_id: None,
18419            },
18420            profile_id: defaults.profile_id.clone(),
18421            expected_profile_revision: defaults.revision.clone(),
18422            overrides: crate::protocol::SpawnOverrides {
18423                network_allowlist: Some(unknown),
18424                browser_profile_id: Some(browser),
18425                ..crate::protocol::SpawnOverrides::default()
18426            },
18427            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18428            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18429                "station-knobs-unknown-allowlist",
18430            )
18431            .unwrap(),
18432            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18433        };
18434        let resolved = spec.resolve(&defaults).unwrap();
18435        let failure = shared
18436            .resolve_environment_profile(&resolved)
18437            .expect_err("unknown allowlist must refuse");
18438        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18439    }
18440
18441    /// Registered network_allowlist (+ browser_profile_id when feature off)
18442    /// echo onto the env-profile receipt. Empty catalog still allows omit
18443    /// (None). Empty/whitespace refuse is type-level. Feature-off
18444    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
18445    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
18446    #[test]
18447    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18448        let (shared, defaults) = station_knobs_fixture();
18449        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18450        #[cfg(not(feature = "dig2-station-probe"))]
18451        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18452        shared
18453            .network_allowlist_catalog
18454            .write()
18455            .unwrap_or_else(|poisoned| poisoned.into_inner())
18456            .insert_id(allowlist.clone())
18457            .unwrap();
18458        let spec = crate::protocol::SpawnSpec {
18459            target: crate::protocol::SpawnTarget {
18460                node_id: shared.node_id.clone(),
18461                workspace_id: WorkspaceId::new("primary").unwrap(),
18462                worktree_id: None,
18463            },
18464            profile_id: defaults.profile_id.clone(),
18465            expected_profile_revision: defaults.revision.clone(),
18466            overrides: crate::protocol::SpawnOverrides {
18467                network_allowlist: Some(allowlist.clone()),
18468                #[cfg(not(feature = "dig2-station-probe"))]
18469                browser_profile_id: Some(browser.clone()),
18470                ..crate::protocol::SpawnOverrides::default()
18471            },
18472            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18473            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18474                .unwrap(),
18475            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18476        };
18477        let resolved = spec.resolve(&defaults).unwrap();
18478        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18479        #[cfg(not(feature = "dig2-station-probe"))]
18480        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18481        let receipt = shared
18482            .resolve_environment_profile(&resolved)
18483            .expect("resolve ok")
18484            .expect("env profile present");
18485        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18486        #[cfg(not(feature = "dig2-station-probe"))]
18487        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18488        #[cfg(feature = "dig2-station-probe")]
18489        assert_eq!(receipt.browser_profile_id, None);
18490        assert_eq!(receipt.profile_id.as_str(), "local-claude");
18491
18492        let omitted = crate::protocol::SpawnSpec {
18493            overrides: crate::protocol::SpawnOverrides::default(),
18494            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18495                .unwrap(),
18496            ..spec
18497        };
18498        let resolved_omit = omitted.resolve(&defaults).unwrap();
18499        let bare = shared
18500            .resolve_environment_profile(&resolved_omit)
18501            .expect("resolve ok")
18502            .expect("env profile present");
18503        assert_eq!(bare.network_allowlist, None);
18504        assert_eq!(bare.browser_profile_id, None);
18505    }
18506
18507    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
18508    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
18509    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
18510    /// unit-tested separately (probe refuses before lease on Linux).
18511    #[cfg(feature = "dig2-station-probe")]
18512    #[test]
18513    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18514        let (shared, defaults) = station_knobs_fixture();
18515        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18516        let spec = crate::protocol::SpawnSpec {
18517            target: crate::protocol::SpawnTarget {
18518                node_id: shared.node_id.clone(),
18519                workspace_id: WorkspaceId::new("primary").unwrap(),
18520                worktree_id: None,
18521            },
18522            profile_id: defaults.profile_id.clone(),
18523            expected_profile_revision: defaults.revision.clone(),
18524            overrides: crate::protocol::SpawnOverrides {
18525                browser_profile_id: Some(browser),
18526                ..crate::protocol::SpawnOverrides::default()
18527            },
18528            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18529            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18530                "station-knobs-probe-refuse",
18531            )
18532            .unwrap(),
18533            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18534        };
18535        let resolved = spec.resolve(&defaults).unwrap();
18536        let failure = shared
18537            .resolve_environment_profile(&resolved)
18538            .expect_err("browser_profile with failing probe must refuse");
18539        #[cfg(windows)]
18540        {
18541            assert!(
18542                matches!(
18543                    failure.code,
18544                    NodeFailureCode::BrowserStationUnreachable
18545                        | NodeFailureCode::BrowserStationProbeUnavailable
18546                ),
18547                "unexpected code {:?}",
18548                failure.code
18549            );
18550        }
18551        #[cfg(not(windows))]
18552        {
18553            assert_eq!(
18554                failure.code,
18555                NodeFailureCode::BrowserStationProbeUnavailable
18556            );
18557        }
18558    }
18559
18560    /// Feature on: omitting browser_profile_id still resolves (no probe).
18561    #[cfg(feature = "dig2-station-probe")]
18562    #[test]
18563    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18564        let (shared, defaults) = station_knobs_fixture();
18565        let spec = crate::protocol::SpawnSpec {
18566            target: crate::protocol::SpawnTarget {
18567                node_id: shared.node_id.clone(),
18568                workspace_id: WorkspaceId::new("primary").unwrap(),
18569                worktree_id: None,
18570            },
18571            profile_id: defaults.profile_id.clone(),
18572            expected_profile_revision: defaults.revision.clone(),
18573            overrides: crate::protocol::SpawnOverrides::default(),
18574            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18575            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18576                "station-knobs-probe-omit",
18577            )
18578            .unwrap(),
18579            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18580        };
18581        let resolved = spec.resolve(&defaults).unwrap();
18582        let receipt = shared
18583            .resolve_environment_profile(&resolved)
18584            .expect("omit browser_profile must not probe")
18585            .expect("env profile present");
18586        assert_eq!(receipt.browser_profile_id, None);
18587    }
18588
18589    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
18590    /// refuses a second holder for the same opaque profile id; release on
18591    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
18592    #[cfg(feature = "dig2-station-probe")]
18593    #[test]
18594    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18595        let (shared, _defaults) = station_knobs_fixture();
18596        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18597        let workspace_id = WorkspaceId::new("primary").unwrap();
18598        let address_a = SessionAddress {
18599            workspace_id: workspace_id.clone(),
18600            session: SessionKey {
18601                instance_id: AgentInstanceId(101),
18602                generation: SessionGeneration(1),
18603            },
18604        };
18605        let address_b = SessionAddress {
18606            workspace_id: workspace_id.clone(),
18607            session: SessionKey {
18608                instance_id: AgentInstanceId(102),
18609                generation: SessionGeneration(1),
18610            },
18611        };
18612        let guard_a = crate::dig2_station_lease::acquire_lease(
18613            &shared.browser_station_leases,
18614            profile.clone(),
18615            address_a.session.instance_id,
18616            1,
18617        )
18618        .expect("first lease");
18619        let busy = crate::dig2_station_lease::acquire_lease(
18620            &shared.browser_station_leases,
18621            profile.clone(),
18622            address_b.session.instance_id,
18623            2,
18624        )
18625        .expect_err("second holder must refuse");
18626        assert!(matches!(
18627            busy,
18628            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18629        ));
18630        // Simulate a bound session so remove_binding clears the lease.
18631        shared.bind_session_with_policy(
18632            &address_a,
18633            ProviderRuntimePolicy::raw_pty(),
18634            None,
18635        );
18636        guard_a.retain();
18637        assert!(shared.remove_binding(&address_a).is_some());
18638        crate::dig2_station_lease::acquire_lease(
18639            &shared.browser_station_leases,
18640            profile,
18641            address_b.session.instance_id,
18642            3,
18643        )
18644        .expect("reacquire after clear")
18645        .retain();
18646        assert!(
18647            crate::dig2_station_lease::release_lease_for_holder(
18648                &shared.browser_station_leases,
18649                address_b.session.instance_id,
18650            )
18651        );
18652    }
18653
18654    /// Feature-off: no browser_station_leases field; stub echo documented in
18655    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
18656    #[cfg(not(feature = "dig2-station-probe"))]
18657    #[test]
18658    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18659        let (shared, defaults) = station_knobs_fixture();
18660        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18661        let spec = crate::protocol::SpawnSpec {
18662            target: crate::protocol::SpawnTarget {
18663                node_id: shared.node_id.clone(),
18664                workspace_id: WorkspaceId::new("primary").unwrap(),
18665                worktree_id: None,
18666            },
18667            profile_id: defaults.profile_id.clone(),
18668            expected_profile_revision: defaults.revision.clone(),
18669            overrides: crate::protocol::SpawnOverrides {
18670                browser_profile_id: Some(browser.clone()),
18671                ..crate::protocol::SpawnOverrides::default()
18672            },
18673            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18674            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18675                .unwrap(),
18676            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18677        };
18678        let resolved = spec.resolve(&defaults).unwrap();
18679        let receipt = shared
18680            .resolve_environment_profile(&resolved)
18681            .expect("stub resolve")
18682            .expect("env profile");
18683        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18684    }
18685
18686
18687    /// Startup file loader registers ids into the empty-default catalog.
18688    /// Feature-off dig2 probe stays stub. Never secrets on C2.
18689    #[test]
18690    fn network_allowlist_catalog_loads_from_temp_file() {
18691        let unique = std::time::SystemTime::now()
18692            .duration_since(std::time::UNIX_EPOCH)
18693            .unwrap()
18694            .as_nanos();
18695        let path = std::env::temp_dir().join(format!(
18696            "gate4agent-node-allowlist-config-{}-{unique}.txt",
18697            std::process::id(),
18698        ));
18699        std::fs::write(
18700            &path,
18701            "# opaque station allowlist ids\negress-default\nlab-egress\n",
18702        )
18703        .unwrap();
18704        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18705            .expect("temp catalog file must load");
18706        let _ = std::fs::remove_file(&path);
18707        assert_eq!(loaded.len(), 2);
18708        let (shared, defaults) = station_knobs_fixture();
18709        {
18710            let mut catalog = shared
18711                .network_allowlist_catalog
18712                .write()
18713                .unwrap_or_else(|poisoned| poisoned.into_inner());
18714            *catalog = loaded;
18715        }
18716        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18717        let spec = crate::protocol::SpawnSpec {
18718            target: crate::protocol::SpawnTarget {
18719                node_id: shared.node_id.clone(),
18720                workspace_id: WorkspaceId::new("primary").unwrap(),
18721                worktree_id: None,
18722            },
18723            profile_id: defaults.profile_id.clone(),
18724            expected_profile_revision: defaults.revision.clone(),
18725            overrides: crate::protocol::SpawnOverrides {
18726                network_allowlist: Some(allowlist.clone()),
18727                ..crate::protocol::SpawnOverrides::default()
18728            },
18729            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18730            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18731                "station-knobs-catalog-file",
18732            )
18733            .unwrap(),
18734            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18735        };
18736        let resolved = spec.resolve(&defaults).unwrap();
18737        let receipt = shared
18738            .resolve_environment_profile(&resolved)
18739            .expect("registered via file load")
18740            .expect("env profile present");
18741        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18742    }
18743
18744    /// Snapshot launch inventory echoes registered catalog ids (opaque).
18745    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
18746    #[test]
18747    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18748        let (shared, _defaults) = station_knobs_fixture();
18749        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18750        shared
18751            .network_allowlist_catalog
18752            .write()
18753            .unwrap_or_else(|poisoned| poisoned.into_inner())
18754            .insert_id(allowlist.clone())
18755            .unwrap();
18756        let snapshot = shared.snapshot();
18757        let inventory = snapshot
18758            .launch_inventory
18759            .expect("launch inventory present");
18760        let ids = inventory
18761            .network_allowlists
18762            .expect("non-empty catalog surfaces ids");
18763        assert_eq!(ids, vec![allowlist]);
18764    }
18765
18766    /// Track B: registered id with provider_native codex_network_access refuses
18767    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
18768    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
18769    #[test]
18770    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18771        let (shared, defaults) = station_knobs_fixture();
18772        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18773        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18774            id: allowlist.clone(),
18775            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18776                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18777                peer: "127.0.0.1:443".to_owned(),
18778            }],
18779            provider_native: Some(
18780                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18781                    codex_network_access: Some(true),
18782                },
18783            ),
18784        };
18785        shared
18786            .network_allowlist_catalog
18787            .write()
18788            .unwrap_or_else(|poisoned| poisoned.into_inner())
18789            .insert(entry)
18790            .unwrap();
18791        let spec = crate::protocol::SpawnSpec {
18792            target: crate::protocol::SpawnTarget {
18793                node_id: shared.node_id.clone(),
18794                workspace_id: WorkspaceId::new("primary").unwrap(),
18795                worktree_id: None,
18796            },
18797            profile_id: defaults.profile_id.clone(),
18798            expected_profile_revision: defaults.revision.clone(),
18799            overrides: crate::protocol::SpawnOverrides {
18800                network_allowlist: Some(allowlist),
18801                ..crate::protocol::SpawnOverrides::default()
18802            },
18803            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18804            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18805                "station-knobs-unsupported-mapping",
18806            )
18807            .unwrap(),
18808            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18809        };
18810        let resolved = spec.resolve(&defaults).unwrap();
18811        assert_eq!(resolved.provider.as_str(), "claude");
18812        let failure = shared
18813            .resolve_environment_profile(&resolved)
18814            .expect_err("claude + codex_network_access must refuse");
18815        assert_eq!(
18816            failure.code,
18817            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18818        );
18819    }
18820
18821    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
18822    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
18823    /// Never secrets on C2.
18824    #[test]
18825    fn resolve_codex_network_access_overlay_moderate_only() {
18826        let (shared, defaults) = station_knobs_fixture();
18827        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18828        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18829            id: allowlist.clone(),
18830            permits: Vec::new(),
18831            provider_native: Some(
18832                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18833                    codex_network_access: Some(true),
18834                },
18835            ),
18836        };
18837        shared
18838            .network_allowlist_catalog
18839            .write()
18840            .unwrap_or_else(|poisoned| poisoned.into_inner())
18841            .insert(entry)
18842            .unwrap();
18843
18844        let overlay = shared
18845            .resolve_codex_network_access_overlay_args(
18846                &agent("codex"),
18847                SessionMode::Pty,
18848                ApprovalLevel::Moderate,
18849                Some(&allowlist),
18850            )
18851            .expect("Moderate Codex maps network_access");
18852        assert_eq!(
18853            overlay,
18854            vec![
18855                std::ffi::OsString::from("-c"),
18856                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18857            ]
18858        );
18859
18860        let acp = shared
18861            .resolve_codex_network_access_overlay_args(
18862                &agent("codex"),
18863                SessionMode::Acp,
18864                ApprovalLevel::Moderate,
18865                Some(&allowlist),
18866            )
18867            .unwrap();
18868        assert_eq!(acp, overlay);
18869
18870        let inline = shared
18871            .resolve_codex_network_access_overlay_args(
18872                &agent("codex"),
18873                SessionMode::Inline,
18874                ApprovalLevel::Moderate,
18875                Some(&allowlist),
18876            )
18877            .unwrap_err();
18878        assert_eq!(
18879            inline.code,
18880            NodeFailureCode::UnsupportedNetworkAllowlistMapping
18881        );
18882
18883        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18884            let spec = crate::protocol::SpawnSpec {
18885                target: crate::protocol::SpawnTarget {
18886                    node_id: shared.node_id.clone(),
18887                    workspace_id: WorkspaceId::new("primary").unwrap(),
18888                    worktree_id: None,
18889                },
18890                profile_id: defaults.profile_id.clone(),
18891                expected_profile_revision: defaults.revision.clone(),
18892                overrides: crate::protocol::SpawnOverrides {
18893                    provider: crate::protocol::SpawnOverride::Set {
18894                        value: agent("codex"),
18895                    },
18896                    approval_level: Some(level),
18897                    network_allowlist: Some(allowlist.clone()),
18898                    ..crate::protocol::SpawnOverrides::default()
18899                },
18900                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18901                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18902                    "codex-net-refuse-{}",
18903                    match level {
18904                        ApprovalLevel::ReadOnly => "ro",
18905                        ApprovalLevel::FullAuto => "fa",
18906                        _ => "other",
18907                    }
18908                ))
18909                .unwrap(),
18910                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18911            };
18912            let resolved = spec.resolve(&defaults).unwrap();
18913            let failure = shared
18914                .resolve_environment_profile(&resolved)
18915                .expect_err("RO/FullAuto must refuse");
18916            assert_eq!(
18917                failure.code,
18918                NodeFailureCode::UnsupportedNetworkAllowlistMapping
18919            );
18920        }
18921    }
18922
18923    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
18924    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
18925    /// Never secrets on C2; does not invent provider_native Claude keys.
18926    #[test]
18927    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18928        let (shared, _defaults) = station_knobs_fixture();
18929        let allowlist =
18930            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18931        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18932            id: allowlist.clone(),
18933            permits: vec![
18934                crate::network_allowlist_catalog::NetworkPermitSketch {
18935                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18936                    peer: "github.com:443".to_owned(),
18937                },
18938                crate::network_allowlist_catalog::NetworkPermitSketch {
18939                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18940                    peer: "*.npmjs.org".to_owned(),
18941                },
18942            ],
18943            provider_native: None,
18944        };
18945        shared
18946            .network_allowlist_catalog
18947            .write()
18948            .unwrap_or_else(|poisoned| poisoned.into_inner())
18949            .insert(entry)
18950            .unwrap();
18951
18952        let unique = std::time::SystemTime::now()
18953            .duration_since(std::time::UNIX_EPOCH)
18954            .unwrap()
18955            .as_nanos();
18956        let provider_home = std::env::temp_dir().join(format!(
18957            "gate4agent-claude-provider-home-{}-{unique}",
18958            std::process::id(),
18959        ));
18960        std::fs::create_dir_all(&provider_home).unwrap();
18961
18962        let overlay = shared
18963            .resolve_network_allowlist_overlay_args(
18964                &agent("claude"),
18965                SessionMode::Pty,
18966                ApprovalLevel::Moderate,
18967                Some(&allowlist),
18968                Some(provider_home.as_path()),
18969            )
18970            .expect("Claude PTY settings overlay on unix");
18971        let settings_path = provider_home.join(
18972            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18973        );
18974        assert_eq!(
18975            overlay,
18976            vec![
18977                std::ffi::OsString::from("--settings"),
18978                std::ffi::OsString::from(settings_path.as_os_str()),
18979            ]
18980        );
18981        let body = std::fs::read_to_string(&settings_path).unwrap();
18982        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18983        assert_eq!(value["sandbox"]["enabled"], true);
18984        assert_eq!(
18985            value["sandbox"]["network"]["allowedDomains"],
18986            serde_json::json!(["github.com", "*.npmjs.org"])
18987        );
18988        assert!(settings_path.starts_with(&provider_home));
18989
18990        let acp = shared
18991            .resolve_network_allowlist_overlay_args(
18992                &agent("claude"),
18993                SessionMode::Acp,
18994                ApprovalLevel::Moderate,
18995                Some(&allowlist),
18996                Some(provider_home.as_path()),
18997            )
18998            .expect_err("Claude ACP must refuse settings overlay");
18999        assert_eq!(
19000            acp.code,
19001            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19002        );
19003
19004        let no_home = shared
19005            .resolve_network_allowlist_overlay_args(
19006                &agent("claude"),
19007                SessionMode::Pty,
19008                ApprovalLevel::Moderate,
19009                Some(&allowlist),
19010                None,
19011            )
19012            .expect_err("Claude overlay requires ProviderHome");
19013        assert_eq!(
19014            no_home.code,
19015            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19016        );
19017
19018        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19019        shared
19020            .network_allowlist_catalog
19021            .write()
19022            .unwrap_or_else(|poisoned| poisoned.into_inner())
19023            .insert_id(bare.clone())
19024            .unwrap();
19025        let empty = shared
19026            .resolve_network_allowlist_overlay_args(
19027                &agent("claude"),
19028                SessionMode::Pty,
19029                ApprovalLevel::Moderate,
19030                Some(&bare),
19031                Some(provider_home.as_path()),
19032            )
19033            .unwrap();
19034        assert!(empty.is_empty());
19035
19036        let _ = std::fs::remove_dir_all(&provider_home);
19037    }
19038
19039    /// Kimi / Grok with permits stay without invented argv overlays.
19040    #[test]
19041    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19042        let (shared, _defaults) = station_knobs_fixture();
19043        let allowlist =
19044            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19045        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19046            id: allowlist.clone(),
19047            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19048                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19049                peer: "example.com:443".to_owned(),
19050            }],
19051            provider_native: None,
19052        };
19053        shared
19054            .network_allowlist_catalog
19055            .write()
19056            .unwrap_or_else(|poisoned| poisoned.into_inner())
19057            .insert(entry)
19058            .unwrap();
19059        for provider in ["kimi", "grok"] {
19060            let overlay = shared
19061                .resolve_network_allowlist_overlay_args(
19062                    &agent(provider),
19063                    SessionMode::Pty,
19064                    ApprovalLevel::Moderate,
19065                    Some(&allowlist),
19066                    None,
19067                )
19068                .unwrap();
19069            assert!(
19070                overlay.is_empty(),
19071                "{provider} must not invent network argv from permits"
19072            );
19073        }
19074    }
19075
19076    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
19077    #[test]
19078    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19079        let unique = std::time::SystemTime::now()
19080            .duration_since(std::time::UNIX_EPOCH)
19081            .unwrap()
19082            .as_nanos();
19083        let path = std::env::temp_dir().join(format!(
19084            "gate4agent-node-allowlist-json-{}-{unique}.json",
19085            std::process::id(),
19086        ));
19087        std::fs::write(
19088            &path,
19089            r#"{
19090              "schema_version": 2,
19091              "entries": [
19092                {
19093                  "id": "egress-default",
19094                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19095                },
19096                { "id": "lab-egress" }
19097              ]
19098            }"#,
19099        )
19100        .unwrap();
19101        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19102            .expect("json v2 catalog must load");
19103        let _ = std::fs::remove_file(&path);
19104        assert_eq!(loaded.len(), 2);
19105        let entry = loaded
19106            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19107            .unwrap();
19108        assert_eq!(entry.permits.len(), 1);
19109        let (shared, defaults) = station_knobs_fixture();
19110        {
19111            let mut catalog = shared
19112                .network_allowlist_catalog
19113                .write()
19114                .unwrap_or_else(|poisoned| poisoned.into_inner());
19115            *catalog = loaded;
19116        }
19117        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19118        let spec = crate::protocol::SpawnSpec {
19119            target: crate::protocol::SpawnTarget {
19120                node_id: shared.node_id.clone(),
19121                workspace_id: WorkspaceId::new("primary").unwrap(),
19122                worktree_id: None,
19123            },
19124            profile_id: defaults.profile_id.clone(),
19125            expected_profile_revision: defaults.revision.clone(),
19126            overrides: crate::protocol::SpawnOverrides {
19127                network_allowlist: Some(allowlist.clone()),
19128                ..crate::protocol::SpawnOverrides::default()
19129            },
19130            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19131            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19132                "station-knobs-catalog-json",
19133            )
19134            .unwrap(),
19135            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19136        };
19137        let resolved = spec.resolve(&defaults).unwrap();
19138        let receipt = shared
19139            .resolve_environment_profile(&resolved)
19140            .expect("permits-only entry ok for claude")
19141            .expect("env profile present");
19142        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19143        let inventory = shared
19144            .snapshot()
19145            .launch_inventory
19146            .expect("launch inventory present");
19147        let ids = inventory.network_allowlists.expect("ids surface");
19148        assert_eq!(
19149            ids,
19150            vec![
19151                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19152                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19153            ]
19154        );
19155    }
19156
19157
19158}
19159
19160
19161#[cfg(test)]
19162mod workspace_environment_collision_tests {
19163    use super::*;
19164    use crate::session_environment::{
19165        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19166    };
19167    use gate4agent_catalog::EnvMutation;
19168    use gate4agent_runtime_native::{
19169        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19170        NativeLaunchProfileId,
19171    };
19172    use std::ffi::OsString;
19173    use std::sync::Arc;
19174
19175    struct EmptyEnvironmentResolver;
19176
19177    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19178        fn resolve_child_environment(
19179            &self,
19180        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19181            Ok(vec![EnvMutation {
19182                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19183                value: None,
19184            }])
19185        }
19186    }
19187
19188    fn agent(value: &str) -> AgentId {
19189        AgentId::new(value).unwrap()
19190    }
19191
19192    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
19193    fn claude_config_dir_collision_fixture() -> (
19194        NodeShared,
19195        ResolvedEnvironmentProfileReceipt,
19196        ResolvedEnvironmentProfileReceipt,
19197    ) {
19198        let catalog = active_registry().unwrap();
19199        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19200        let control = runtime.native_launch_profile_control();
19201        let workspace = WorkspaceConfig::new(
19202            WorkspaceId::new("primary").unwrap(),
19203            std::env::current_dir().unwrap(),
19204        )
19205        .unwrap();
19206        let shared = NodeShared::new_with_incarnation(
19207            handle,
19208            "fixture-token".to_owned(),
19209            NodeId::new("node-env-collision-test").unwrap(),
19210            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19211            vec![workspace],
19212            vec![agent("claude")],
19213            ProviderRuntimeStatuses::default(),
19214            None,
19215            Vec::new(),
19216            Vec::new(),
19217            SpawnProfileRegistry::default(),
19218            Some(control),
19219            None,
19220            None,
19221            None,
19222            Vec::new(),
19223            Vec::new(),
19224            Vec::new(),
19225            Vec::new(),
19226            None,
19227            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19228        );
19229
19230        let mut receipts = Vec::new();
19231        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19232            let profile_id =
19233                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19234            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19235                "claude-config-dir-{suffix}-r1"
19236            ))
19237            .unwrap();
19238            let materialization = NodeSessionMaterializationProfile::new(
19239                Vec::new(),
19240                vec![NodeSessionPathBinding::new(
19241                    "CLAUDE_CONFIG_DIR",
19242                    NodeSessionPathClass::ProviderHome,
19243                )
19244                .unwrap()],
19245                Vec::new(),
19246            )
19247            .unwrap();
19248            let node_profile = NodeEnvironmentProfile::new_with_materialization(
19249                profile_id.clone(),
19250                profile_revision.clone(),
19251                agent("claude"),
19252                [NativeLaunchProfile::new(
19253                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19254                        .unwrap(),
19255                    agent("claude"),
19256                    TransportKind::Pty,
19257                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19258                    Arc::new(EmptyEnvironmentResolver),
19259                )
19260                .unwrap()],
19261                Some(materialization),
19262            )
19263            .unwrap();
19264            let (binding, native_profiles, materialization) = node_profile.into_parts();
19265            for native_profile in native_profiles {
19266                runtime.upsert_native_launch_profile(native_profile).unwrap();
19267            }
19268            shared
19269                .environment_profiles
19270                .write()
19271                .unwrap_or_else(|poisoned| poisoned.into_inner())
19272                .insert(binding.id.clone(), binding);
19273            shared
19274                .environment_materialization_profiles
19275                .write()
19276                .unwrap_or_else(|poisoned| poisoned.into_inner())
19277                .insert(profile_id.clone(), materialization.unwrap());
19278            receipts.push(ResolvedEnvironmentProfileReceipt {
19279                profile_id,
19280                profile_revision,
19281                network_allowlist: None,
19282                browser_profile_id: None,
19283            });
19284        }
19285        let profile_b = receipts.pop().unwrap();
19286        let profile_a = receipts.pop().unwrap();
19287        (shared, profile_a, profile_b)
19288    }
19289
19290    #[test]
19291    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19292        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19293        let primary = WorkspaceId::new("primary").unwrap();
19294        let worktree = WorkspaceId::new("worktree-b").unwrap();
19295        let address = SessionAddress {
19296            workspace_id: primary.clone(),
19297            session: SessionKey {
19298                instance_id: AgentInstanceId(41),
19299                generation: SessionGeneration(1),
19300            },
19301        };
19302        shared.bind_session_with_policy(
19303            &address,
19304            ProviderRuntimePolicy::raw_pty(),
19305            Some(profile_a.clone()),
19306        );
19307
19308        let refused = shared
19309            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19310            .unwrap_err();
19311        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19312
19313        // Some vs None on the same cwd is also a collision.
19314        let refused_none = shared
19315            .ensure_workspace_environment_profile_compatible(&primary, None)
19316            .unwrap_err();
19317        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19318
19319        // Same profile on the same workspace remains allowed.
19320        shared
19321            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19322            .unwrap();
19323
19324        // Distinct workspace id (managed-worktree parallel path) allows profile B.
19325        shared
19326            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19327            .unwrap();
19328    }
19329
19330    #[tokio::test]
19331    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19332        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19333        let primary = WorkspaceId::new("primary").unwrap();
19334        let address = SessionAddress {
19335            workspace_id: primary.clone(),
19336            session: SessionKey {
19337                instance_id: AgentInstanceId(41),
19338                generation: SessionGeneration(1),
19339            },
19340        };
19341        shared.bind_session_with_policy(
19342            &address,
19343            ProviderRuntimePolicy::raw_pty(),
19344            Some(profile_a),
19345        );
19346
19347        let failure = shared
19348            .spawn_session_with_deadline(
19349                primary,
19350                agent("claude"),
19351                SessionMode::Pty,
19352                gate4agent_types::TerminalSize {
19353                    rows: 24,
19354                    columns: 80,
19355                },
19356                None,
19357                Some(profile_b),
19358                None,
19359                None,
19360                None,
19361                None,
19362                SpawnRecordPolicy::ProviderIdentityOnly,
19363                Some(Instant::now() + Duration::from_secs(30)),
19364                &[],
19365                None,
19366                ApprovalLevel::default(),
19367                None,
19368                None,
19369            )
19370            .await
19371            .unwrap_err();
19372        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19373        // Guard runs before instance id mint / bind.
19374        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19375        assert!(shared.handle.snapshot().sessions.is_empty());
19376    }
19377}
19378
19379#[cfg(test)]
19380mod spawn_characterization_tests {
19381    use super::*;
19382    use crate::session_environment::{
19383        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19384        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19385        NodeSessionPathClass,
19386    };
19387    use gate4agent_catalog::EnvMutation;
19388    use gate4agent_runtime_native::{
19389        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19390        NativeLaunchProfile, NativeLaunchProfileId,
19391    };
19392    use std::ffi::OsString;
19393    use std::sync::Arc;
19394
19395    struct EmptyEnvironmentResolver;
19396
19397    struct FixtureSecretResolver {
19398        deny: Arc<AtomicBool>,
19399    }
19400
19401    impl NodeSecretResolver for FixtureSecretResolver {
19402        fn resolve(
19403            &self,
19404            _reference: &NodeSecretReference,
19405        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19406            if self.deny.load(Ordering::Acquire) {
19407                Err(NodeSecretResolveError::Denied)
19408            } else {
19409                NodeSecretValue::text("fixture-secret")
19410                    .map_err(|_| NodeSecretResolveError::Unavailable)
19411            }
19412        }
19413    }
19414
19415    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19416        fn resolve_child_environment(
19417            &self,
19418        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19419            Ok(vec![EnvMutation {
19420                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19421                value: None,
19422            }])
19423        }
19424    }
19425
19426    fn agent(value: &str) -> AgentId {
19427        AgentId::new(value).unwrap()
19428    }
19429
19430    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19431        let unique = std::time::SystemTime::now()
19432            .duration_since(std::time::UNIX_EPOCH)
19433            .unwrap()
19434            .as_nanos();
19435        std::env::temp_dir().join(format!(
19436            "gate4agent-node-{label}-{}-{unique}",
19437            std::process::id(),
19438        ))
19439    }
19440
19441    fn materialization_test_shared(
19442        deny_secret: bool,
19443    ) -> (
19444        NodeShared,
19445        NativeRuntime,
19446        ResolvedEnvironmentProfileReceipt,
19447        PathBuf,
19448        Arc<AtomicBool>,
19449    ) {
19450        let workspace = WorkspaceConfig::new(
19451            WorkspaceId::new("primary").unwrap(),
19452            std::env::current_dir().unwrap(),
19453        )
19454        .unwrap();
19455        materialization_test_shared_with_workspace(deny_secret, workspace)
19456    }
19457
19458    fn materialization_test_shared_with_workspace(
19459        deny_secret: bool,
19460        workspace: WorkspaceConfig,
19461    ) -> (
19462        NodeShared,
19463        NativeRuntime,
19464        ResolvedEnvironmentProfileReceipt,
19465        PathBuf,
19466        Arc<AtomicBool>,
19467    ) {
19468        let root = temporary_workspace_root(if deny_secret {
19469            "materialization-denied"
19470        } else {
19471            "materialization-ready"
19472        });
19473        std::fs::create_dir_all(&root).unwrap();
19474        let materialization_root = root.join("session-environments");
19475        let deny = Arc::new(AtomicBool::new(deny_secret));
19476        let resolver = Arc::new(FixtureSecretResolver {
19477            deny: Arc::clone(&deny),
19478        });
19479        let materializer = SessionEnvironmentMaterializer::new(
19480            materialization_root,
19481            resolver,
19482        )
19483        .unwrap();
19484        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19485        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19486            "materialized-claude-r1",
19487        )
19488        .unwrap();
19489        let materialization = NodeSessionMaterializationProfile::new(
19490            vec![NodeSessionEnvironmentMutation::SetSecret {
19491                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19492                reference: NodeSecretReference::new("fixture-token").unwrap(),
19493            }],
19494            vec![NodeSessionPathBinding::new(
19495                "GATE4AGENT_PROVIDER_HOME",
19496                NodeSessionPathClass::ProviderHome,
19497            )
19498            .unwrap()],
19499            vec![NodeSessionFile::secret(
19500                NodeSessionPathClass::Config,
19501                "auth/token",
19502                NodeSecretReference::new("fixture-token").unwrap(),
19503            )
19504            .unwrap()],
19505        )
19506        .unwrap();
19507        let catalog = active_registry().unwrap();
19508        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19509        let control = runtime.native_launch_profile_control();
19510        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19511            profile_id.clone(),
19512            profile_revision.clone(),
19513            agent("claude"),
19514            [NativeLaunchProfile::new(
19515                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19516                agent("claude"),
19517                TransportKind::Pty,
19518                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19519                Arc::new(EmptyEnvironmentResolver),
19520            )
19521            .unwrap()],
19522            Some(materialization),
19523        )
19524        .unwrap();
19525        let (binding, native_profiles, materialization) = node_profile.into_parts();
19526        for native_profile in native_profiles {
19527            runtime.upsert_native_launch_profile(native_profile).unwrap();
19528        }
19529        let shared = NodeShared::new_with_incarnation(
19530            handle,
19531            "fixture-token".to_owned(),
19532            NodeId::new("node-materialization-test").unwrap(),
19533            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19534            vec![workspace],
19535            vec![agent("claude")],
19536            ProviderRuntimeStatuses::default(),
19537            None,
19538            Vec::new(),
19539            Vec::new(),
19540            SpawnProfileRegistry::default(),
19541            Some(control),
19542            None,
19543            Some(materializer),
19544            None,
19545            Vec::new(),
19546            Vec::new(),
19547            Vec::new(),
19548            Vec::new(),
19549            None,
19550            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19551        );
19552        shared
19553            .environment_profiles
19554            .write()
19555            .unwrap_or_else(|poisoned| poisoned.into_inner())
19556            .insert(binding.id.clone(), binding);
19557        shared
19558            .environment_materialization_profiles
19559            .write()
19560            .unwrap_or_else(|poisoned| poisoned.into_inner())
19561            .insert(profile_id.clone(), materialization.unwrap());
19562        (
19563            shared,
19564            runtime,
19565            ResolvedEnvironmentProfileReceipt {
19566                profile_id,
19567                profile_revision,
19568                network_allowlist: None,
19569                browser_profile_id: None,
19570            },
19571            root,
19572            deny,
19573        )
19574    }
19575
19576
19577    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
19578
19579    async fn characterization_spawn(
19580        shared: &NodeShared,
19581        mode: SessionMode,
19582        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19583        required: &[ProviderRuntimeCapability],
19584        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19585    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19586        shared
19587            .spawn_session_with_deadline(
19588                WorkspaceId::new("primary").unwrap(),
19589                agent("claude"),
19590                mode,
19591                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19592                None,
19593                environment_profile,
19594                None,
19595                None,
19596                None,
19597                Some(ProviderRuntimePolicy::raw_pty()),
19598                SpawnRecordPolicy::ProviderIdentityOnly,
19599                Some(Instant::now() + Duration::from_secs(30)),
19600                required,
19601                harness_mcp,
19602                ApprovalLevel::default(),
19603                None,
19604                None,
19605            )
19606            .await
19607    }
19608
19609    fn assert_no_session_state(shared: &NodeShared) {
19610        assert!(shared.handle.snapshot().sessions.is_empty());
19611        assert!(shared
19612            .session_bindings
19613            .lock()
19614            .unwrap_or_else(|poisoned| poisoned.into_inner())
19615            .is_empty());
19616        assert!(shared
19617            .materializations
19618            .lock()
19619            .unwrap_or_else(|poisoned| poisoned.into_inner())
19620            .is_empty());
19621    }
19622
19623    #[tokio::test]
19624    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19625        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19626        let error = characterization_spawn(
19627            &shared,
19628            SessionMode::Pty,
19629            None,
19630            &[ProviderRuntimeCapability::SemanticReadiness],
19631            None,
19632        )
19633        .await
19634        .unwrap_err();
19635        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19636        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19637        assert_no_session_state(&shared);
19638        drop(shared);
19639        drop(runtime);
19640        std::fs::remove_dir_all(root).unwrap();
19641    }
19642
19643    #[tokio::test]
19644    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19645        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19646        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19647            .await
19648            .unwrap_err();
19649        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19650        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19651        assert_no_session_state(&shared);
19652        drop(shared);
19653        drop(runtime);
19654        std::fs::remove_dir_all(root).unwrap();
19655    }
19656
19657    #[test]
19658    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19659        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19660        let address = SessionAddress {
19661            workspace_id: WorkspaceId::new("primary").unwrap(),
19662            session: SessionKey {
19663                instance_id: AgentInstanceId(77),
19664                generation: SessionGeneration(1),
19665            },
19666        };
19667        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19668        let removed = shared.remove_binding(&address).expect("binding removed");
19669        assert!(removed.environment_profile.is_some());
19670        assert!(shared.remove_binding(&address).is_none());
19671        assert_no_session_state(&shared);
19672        drop(shared);
19673        drop(runtime);
19674        std::fs::remove_dir_all(root).unwrap();
19675    }
19676
19677    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19678        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19679        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19680        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19681            .expect("review current test executable as fixture helper");
19682        let registry = HarnessMcpProxyRegistry::new(helper);
19683        let spec = crate::protocol::SpawnSpec {
19684            target: crate::protocol::SpawnTarget {
19685                node_id: NodeId::new("node-characterization").unwrap(),
19686                workspace_id: WorkspaceId::new("primary").unwrap(),
19687                worktree_id: None,
19688            },
19689            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19690            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19691            overrides: crate::protocol::SpawnOverrides::default(),
19692            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19693            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19694            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19695        };
19696        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19697        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19698        let launch = HarnessMcpLaunchV1 {
19699            server_name: "fixture-harness".to_owned(),
19700            args: vec!["--session-proxy".to_owned()],
19701            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19702            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19703            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19704            trace: None,
19705            scrub_env: Vec::new(),
19706        };
19707        let expires = unix_time_ms().saturating_add(30_000);
19708        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19709        registry
19710            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19711            .unwrap()
19712    }
19713
19714    #[tokio::test]
19715    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19716        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19717        let prepared = characterization_prepared_harness("claude").await;
19718        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19719            .await
19720            .unwrap_err();
19721        // Inline is stopped by runtime admission before the harness check runs.
19722        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19723        let mismatched = characterization_prepared_harness("codex").await;
19724        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19725            .await
19726            .unwrap_err();
19727        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19728        assert_no_session_state(&shared);
19729        drop(shared);
19730        drop(runtime);
19731        std::fs::remove_dir_all(root).unwrap();
19732    }
19733
19734    #[tokio::test]
19735    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19736        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19737        shared.native_launch_profile_control = None;
19738        let prepared = characterization_prepared_harness("claude").await;
19739        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19740            .await
19741            .unwrap_err();
19742        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19743        assert_no_session_state(&shared);
19744        drop(shared);
19745        drop(runtime);
19746        std::fs::remove_dir_all(root).unwrap();
19747    }
19748}
19749
19750#[cfg(all(test, windows))]
19751mod tests {
19752    use super::*;
19753    use gate4agent_types::ProviderActivity;
19754    use crate::session_environment::{
19755        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19756        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19757        NodeSessionPathClass,
19758    };
19759    use gate4agent_catalog::EnvMutation;
19760    use gate4agent_runtime_native::{
19761        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19762        NativeLaunchProfile, NativeLaunchProfileId,
19763    };
19764    use std::ffi::OsString;
19765    use std::sync::Arc;
19766
19767    struct EmptyEnvironmentResolver;
19768
19769    struct FixtureSecretResolver {
19770        deny: Arc<AtomicBool>,
19771    }
19772
19773    impl NodeSecretResolver for FixtureSecretResolver {
19774        fn resolve(
19775            &self,
19776            _reference: &NodeSecretReference,
19777        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19778            if self.deny.load(Ordering::Acquire) {
19779                Err(NodeSecretResolveError::Denied)
19780            } else {
19781                NodeSecretValue::text("fixture-secret")
19782                    .map_err(|_| NodeSecretResolveError::Unavailable)
19783            }
19784        }
19785    }
19786
19787    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19788        fn resolve_child_environment(
19789            &self,
19790        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19791            Ok(vec![EnvMutation {
19792                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19793                value: None,
19794            }])
19795        }
19796    }
19797
19798    fn agent(value: &str) -> AgentId {
19799        AgentId::new(value).unwrap()
19800    }
19801
19802    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19803        let catalog = active_registry().unwrap();
19804        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19805        let workspace = WorkspaceConfig::new(
19806            WorkspaceId::new("primary").unwrap(),
19807            std::env::current_dir().unwrap(),
19808        )
19809        .unwrap()
19810        .with_worktree_service_mode(mode);
19811        NodeShared::new(
19812            handle,
19813            "fixture-token".to_owned(),
19814            NodeId::new("node-terminal-test").unwrap(),
19815            vec![workspace],
19816            vec![agent("claude")],
19817        )
19818    }
19819
19820    fn terminal_test_shared() -> NodeShared {
19821        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19822    }
19823
19824    #[cfg(feature = "fixture")]
19825    #[test]
19826    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19827        let probe = SpawnManagedWorktreeV2FailureProbe::default();
19828        assert_eq!(probe.latest_code(), None);
19829
19830        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19831        assert_eq!(
19832            probe.latest_code(),
19833            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19834        );
19835
19836        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19837        assert_eq!(
19838            probe.latest_code(),
19839            Some(NodeFailureCode::SpawnDeadlineExceeded),
19840        );
19841
19842        probe.record(None);
19843        assert_eq!(probe.latest_code(), None);
19844    }
19845
19846    #[test]
19847    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19848        let source = temporary_workspace_root("launch-inventory-source");
19849        let allocation = temporary_workspace_root("launch-inventory-allocation");
19850        std::fs::create_dir_all(&source).unwrap();
19851        std::fs::create_dir_all(&allocation).unwrap();
19852        let workspace_id = WorkspaceId::new("primary").unwrap();
19853        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19854            .unwrap()
19855            .with_worktree_service_mode(WorktreeServiceMode::Managed);
19856        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19857            workspace = workspace
19858                .with_managed_worktree_profile(
19859                    ManagedWorktreeProfile::new(
19860                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19861                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19862                        &allocation,
19863                        "gate4agent",
19864                        "HEAD",
19865                        ManagedWorktreeRetention::RemoveWhenReleased,
19866                    )
19867                    .unwrap(),
19868                )
19869                .unwrap();
19870        }
19871        let overflow = workspace.clone().with_managed_worktree_profile(
19872            ManagedWorktreeProfile::new(
19873                WorktreeProfileId::new("overflow").unwrap(),
19874                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19875                &allocation,
19876                "gate4agent",
19877                "HEAD",
19878                ManagedWorktreeRetention::Retain,
19879            )
19880            .unwrap(),
19881        );
19882        assert!(matches!(
19883            overflow,
19884            Err(NodeServerError::ManagedWorktreeProfileCapacity {
19885                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19886                ..
19887            }),
19888        ));
19889
19890        let catalog = active_registry().unwrap();
19891        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19892        let shared = NodeShared::new(
19893            handle,
19894            "fixture-token".to_owned(),
19895            NodeId::new("node-launch-inventory").unwrap(),
19896            vec![workspace],
19897            vec![agent("claude")],
19898        );
19899        let snapshot = shared.snapshot();
19900        let launch = snapshot.launch_inventory.as_ref().unwrap();
19901        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19902        assert!(launch.bundles.as_ref().unwrap().is_empty());
19903        let profiles = snapshot.workspaces[0]
19904            .managed_worktree_profiles
19905            .as_ref()
19906            .unwrap();
19907        assert_eq!(
19908            snapshot.workspaces[0].worktree_service_mode,
19909            Some(WorktreeServiceMode::Managed),
19910        );
19911        assert_eq!(
19912            profiles.profiles.len(),
19913            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19914        );
19915        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19916        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19917            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19918        }
19919        let legacy = snapshot_for_wire(
19920            &shared,
19921            true,
19922            true,
19923            false,
19924            false,
19925            true,
19926            false,
19927            true,
19928            false,
19929            true,
19930        );
19931        assert!(legacy.launch_inventory.is_none());
19932        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19933
19934        drop(shared);
19935        std::fs::remove_dir_all(source).unwrap();
19936        std::fs::remove_dir_all(allocation).unwrap();
19937    }
19938
19939    #[test]
19940    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19941        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19942            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19943        }));
19944        let source = gate4agent_types::ProviderSource {
19945            family: AdapterFamily::PtySemantic,
19946            binding: AdapterBinding::new(
19947                gate4agent_types::AdapterId::new("claude").unwrap(),
19948                "fixture/v1",
19949                gate4agent_types::AdapterVerification::SyntheticFixture,
19950            )
19951            .unwrap(),
19952        };
19953        let provider = ProviderSnapshot {
19954            sequence: 17,
19955            session: Some(ProviderSessionIdentity {
19956                key: ProviderSessionKey::SessionId,
19957                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19958                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
19959            }),
19960            model: Some("PRIVATE_MODEL".to_owned()),
19961            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
19962            completed_turns: 3,
19963            usage: gate4agent_types::TokenUsage {
19964                input_tokens: 10,
19965                output_tokens: 20,
19966                cache_read_tokens: 30,
19967                cache_write_tokens: 40,
19968                reasoning_tokens: 50,
19969                context_window: Some(200_000),
19970            },
19971            lead_activity: ProviderActivity::WaitingForInput,
19972            activity: ProviderActivity::WaitingForInput,
19973            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
19974            active_tools: (0..9)
19975                .map(|index| gate4agent_types::ActiveProviderTool {
19976                    id: format!("private-tool-id-{index}"),
19977                    name: if index == 0 {
19978                        format!("  Read\u{0000}{}", "x".repeat(80))
19979                    } else {
19980                        format!("Tool-{index}")
19981                    },
19982                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
19983                })
19984                .collect(),
19985            interactions: vec![gate4agent_types::ProviderInteraction {
19986                id: gate4agent_types::ProviderInteractionId(17),
19987                source: source.clone(),
19988                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
19989                interaction_kind: ProviderInteractionKind::Approval,
19990                tool_name: " Approve\u{0007}Tool ".to_owned(),
19991                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
19992                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
19993                resume_lead_activity: Some(ProviderActivity::Working),
19994                status: ProviderInteractionStatus::Pending,
19995            }],
19996            subagents: vec![gate4agent_types::ProviderSubagent {
19997                source: source.clone(),
19998                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
19999                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20000                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20001            }],
20002            sources: vec![gate4agent_types::ProviderSourceCursor {
20003                source,
20004                sequence: 17,
20005                gap_count: 2,
20006                stale: true,
20007            }],
20008            last_event: Some(ProviderEvent::Error {
20009                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20010            }),
20011            gap_count: 2,
20012            stale: true,
20013        };
20014        let address = terminal_address(3);
20015        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20016        assert_eq!(entry.address, address);
20017        assert_eq!(entry.progress.provider_sequence, 17);
20018        assert_eq!(entry.progress.active_tool_count, 9);
20019        assert_eq!(
20020            entry.progress.active_tool_labels.len(),
20021            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20022        );
20023        assert_eq!(entry.progress.subagent_count, 1);
20024        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20025        assert!(entry.progress.truncated);
20026        let encoded = serde_json::to_string(&entry).unwrap();
20027        for forbidden in [
20028            "PRIVATE_PROVIDER_SESSION",
20029            "PRIVATE_PROVIDER_PATH",
20030            "PRIVATE_MODEL",
20031            "PRIVATE_TOOL_INVENTORY",
20032            "PRIVATE_CURRENT_PROMPT",
20033            "PRIVATE_TOOL_INPUT_JSON",
20034            "PRIVATE_PROVIDER_REQUEST",
20035            "PRIVATE_INTERACTION_PROMPT",
20036            "PRIVATE_INTERACTION_AGENT",
20037            "PRIVATE_SUBAGENT_ID",
20038            "PRIVATE_SUBAGENT_TYPE",
20039            "PRIVATE_SUBAGENT_DESCRIPTION",
20040            "PRIVATE_PROVIDER_ERROR",
20041            "input_json",
20042            "current_prompt",
20043            "session_id",
20044        ] {
20045            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20046        }
20047
20048        let snapshot = NodeSnapshot {
20049            node_id: NodeId::new("node-progress-test").unwrap(),
20050            enabled_providers: vec![agent("claude")],
20051            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20052            workspaces: Vec::new(),
20053            session_records: Vec::new(),
20054            managed_worktrees: Vec::new(),
20055            launch_inventory: None,
20056            agent_progress: vec![entry],
20057        };
20058        let mut legacy_reply = ResponseEnvelope {
20059            request_id: 1,
20060            result: Ok(NodeResponse::Snapshot {
20061                event_sequence: 0,
20062                controller: None,
20063                snapshot: snapshot.clone(),
20064            }),
20065        };
20066        project_response_without_agent_progress(&mut legacy_reply);
20067        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20068            panic!("agent progress projection changed response kind");
20069        };
20070        assert!(legacy.agent_progress.is_empty());
20071        assert_eq!(snapshot.agent_progress.len(), 1);
20072    }
20073
20074    fn terminal_address(generation: u64) -> SessionAddress {
20075        SessionAddress {
20076            workspace_id: WorkspaceId::new("primary").unwrap(),
20077            session: SessionKey {
20078                instance_id: AgentInstanceId(41),
20079                generation: SessionGeneration(generation),
20080            },
20081        }
20082    }
20083
20084    fn spawn_spec_fixture() -> SpawnSpec {
20085        SpawnSpec {
20086            target: crate::protocol::SpawnTarget {
20087                node_id: NodeId::new("node-terminal-test").unwrap(),
20088                workspace_id: WorkspaceId::new("primary").unwrap(),
20089                worktree_id: None,
20090            },
20091            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20092            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20093                .unwrap(),
20094            overrides: crate::protocol::SpawnOverrides::default(),
20095            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20096            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20097            required_capabilities: SpawnRequiredCapabilities::default(),
20098        }
20099    }
20100
20101    fn environment_profile_test_shared(
20102    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20103        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20104        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20105            "local-claude-r1",
20106        )
20107        .unwrap();
20108        let spawn_profiles = SpawnProfileRegistry::new([
20109            crate::protocol::SpawnProfileDefaults {
20110                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20111                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20112                provider: agent("claude"),
20113                mode: SessionMode::Pty,
20114                terminal_size: gate4agent_types::TerminalSize {
20115                    rows: 24,
20116                    columns: 80,
20117                },
20118                prompt: None,
20119                bundle_id: None,
20120                context_id: None,
20121                environment_profile_id: Some(profile_id.clone()),
20122            },
20123        ])
20124        .unwrap();
20125        let catalog = active_registry().unwrap();
20126        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20127        let control = runtime.native_launch_profile_control();
20128        let node_profile = NodeEnvironmentProfile::new(
20129            profile_id.clone(),
20130            profile_revision.clone(),
20131            agent("claude"),
20132            [NativeLaunchProfile::new(
20133                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20134                agent("claude"),
20135                TransportKind::Pty,
20136                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20137                Arc::new(EmptyEnvironmentResolver),
20138            )
20139            .unwrap()],
20140        )
20141        .unwrap();
20142        let (binding, native_profiles, _materialization) = node_profile.into_parts();
20143        for native_profile in native_profiles {
20144            runtime.upsert_native_launch_profile(native_profile).unwrap();
20145        }
20146        let workspace = WorkspaceConfig::new(
20147            WorkspaceId::new("primary").unwrap(),
20148            std::env::current_dir().unwrap(),
20149        )
20150        .unwrap();
20151        let shared = NodeShared::new_with_incarnation(
20152            handle,
20153            "fixture-token".to_owned(),
20154            NodeId::new("node-terminal-test").unwrap(),
20155            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20156            vec![workspace],
20157            vec![agent("claude")],
20158            ProviderRuntimeStatuses::default(),
20159            None,
20160            Vec::new(),
20161            Vec::new(),
20162            spawn_profiles,
20163            Some(control),
20164            None,
20165            None,
20166            None,
20167            Vec::new(),
20168            Vec::new(),
20169            Vec::new(),
20170            Vec::new(),
20171            None,
20172            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20173        );
20174        shared
20175            .environment_profiles
20176            .write()
20177            .unwrap_or_else(|poisoned| poisoned.into_inner())
20178            .insert(binding.id.clone(), binding);
20179        (
20180            shared,
20181            runtime,
20182            ResolvedEnvironmentProfileReceipt {
20183                profile_id,
20184                profile_revision,
20185                network_allowlist: None,
20186                browser_profile_id: None,
20187            },
20188        )
20189    }
20190
20191    fn materialization_test_shared(
20192        deny_secret: bool,
20193    ) -> (
20194        NodeShared,
20195        NativeRuntime,
20196        ResolvedEnvironmentProfileReceipt,
20197        PathBuf,
20198        Arc<AtomicBool>,
20199    ) {
20200        let workspace = WorkspaceConfig::new(
20201            WorkspaceId::new("primary").unwrap(),
20202            std::env::current_dir().unwrap(),
20203        )
20204        .unwrap();
20205        materialization_test_shared_with_workspace(deny_secret, workspace)
20206    }
20207
20208    fn materialization_test_shared_with_workspace(
20209        deny_secret: bool,
20210        workspace: WorkspaceConfig,
20211    ) -> (
20212        NodeShared,
20213        NativeRuntime,
20214        ResolvedEnvironmentProfileReceipt,
20215        PathBuf,
20216        Arc<AtomicBool>,
20217    ) {
20218        let root = temporary_workspace_root(if deny_secret {
20219            "materialization-denied"
20220        } else {
20221            "materialization-ready"
20222        });
20223        std::fs::create_dir_all(&root).unwrap();
20224        let materialization_root = root.join("session-environments");
20225        let deny = Arc::new(AtomicBool::new(deny_secret));
20226        let resolver = Arc::new(FixtureSecretResolver {
20227            deny: Arc::clone(&deny),
20228        });
20229        let materializer = SessionEnvironmentMaterializer::new(
20230            materialization_root,
20231            resolver,
20232        )
20233        .unwrap();
20234        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20235        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20236            "materialized-claude-r1",
20237        )
20238        .unwrap();
20239        let materialization = NodeSessionMaterializationProfile::new(
20240            vec![NodeSessionEnvironmentMutation::SetSecret {
20241                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20242                reference: NodeSecretReference::new("fixture-token").unwrap(),
20243            }],
20244            vec![NodeSessionPathBinding::new(
20245                "GATE4AGENT_PROVIDER_HOME",
20246                NodeSessionPathClass::ProviderHome,
20247            )
20248            .unwrap()],
20249            vec![NodeSessionFile::secret(
20250                NodeSessionPathClass::Config,
20251                "auth/token",
20252                NodeSecretReference::new("fixture-token").unwrap(),
20253            )
20254            .unwrap()],
20255        )
20256        .unwrap();
20257        let catalog = active_registry().unwrap();
20258        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20259        let control = runtime.native_launch_profile_control();
20260        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20261            profile_id.clone(),
20262            profile_revision.clone(),
20263            agent("claude"),
20264            [NativeLaunchProfile::new(
20265                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20266                agent("claude"),
20267                TransportKind::Pty,
20268                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20269                Arc::new(EmptyEnvironmentResolver),
20270            )
20271            .unwrap()],
20272            Some(materialization),
20273        )
20274        .unwrap();
20275        let (binding, native_profiles, materialization) = node_profile.into_parts();
20276        for native_profile in native_profiles {
20277            runtime.upsert_native_launch_profile(native_profile).unwrap();
20278        }
20279        let shared = NodeShared::new_with_incarnation(
20280            handle,
20281            "fixture-token".to_owned(),
20282            NodeId::new("node-materialization-test").unwrap(),
20283            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20284            vec![workspace],
20285            vec![agent("claude")],
20286            ProviderRuntimeStatuses::default(),
20287            None,
20288            Vec::new(),
20289            Vec::new(),
20290            SpawnProfileRegistry::default(),
20291            Some(control),
20292            None,
20293            Some(materializer),
20294            None,
20295            Vec::new(),
20296            Vec::new(),
20297            Vec::new(),
20298            Vec::new(),
20299            None,
20300            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20301        );
20302        shared
20303            .environment_profiles
20304            .write()
20305            .unwrap_or_else(|poisoned| poisoned.into_inner())
20306            .insert(binding.id.clone(), binding);
20307        shared
20308            .environment_materialization_profiles
20309            .write()
20310            .unwrap_or_else(|poisoned| poisoned.into_inner())
20311            .insert(profile_id.clone(), materialization.unwrap());
20312        (
20313            shared,
20314            runtime,
20315            ResolvedEnvironmentProfileReceipt {
20316                profile_id,
20317                profile_revision,
20318                network_allowlist: None,
20319                browser_profile_id: None,
20320            },
20321            root,
20322            deny,
20323        )
20324    }
20325
20326    #[cfg(feature = "fixture")]
20327    #[derive(Clone, Copy)]
20328    enum CodexHomeProfile {
20329        Missing,
20330        WrongClass,
20331        Exact,
20332    }
20333
20334    #[cfg(feature = "fixture")]
20335    fn codex_bundle_test_shared(
20336        home_profile: CodexHomeProfile,
20337    ) -> (
20338        NodeShared,
20339        NativeRuntime,
20340        ResolvedEnvironmentProfileReceipt,
20341        NodeBundle,
20342        PathBuf,
20343    ) {
20344        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20345        const CLAUDE_MANIFEST: &[u8] =
20346            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20347        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20348        const DIGEST: &str =
20349            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20350
20351        let (shared, mut runtime, _claude_receipt, root, _deny) =
20352            materialization_test_shared(false);
20353        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20354            CodexHomeProfile::Missing => "codex-home-missing",
20355            CodexHomeProfile::WrongClass => "codex-home-wrong",
20356            CodexHomeProfile::Exact => "codex-home-exact",
20357        })
20358        .unwrap();
20359        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20360            "codex-home-r1",
20361        )
20362        .unwrap();
20363        let materialization = match home_profile {
20364            CodexHomeProfile::Missing => None,
20365            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20366                Some(NodeSessionMaterializationProfile::new(
20367                    Vec::new(),
20368                    vec![NodeSessionPathBinding::new(
20369                        "CODEX_HOME",
20370                        if matches!(home_profile, CodexHomeProfile::Exact) {
20371                            NodeSessionPathClass::ProviderHome
20372                        } else {
20373                            NodeSessionPathClass::Config
20374                        },
20375                    )
20376                    .unwrap()],
20377                    Vec::new(),
20378                )
20379                .unwrap())
20380            }
20381        };
20382        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20383            profile_id.clone(),
20384            profile_revision.clone(),
20385            agent("codex"),
20386            [NativeLaunchProfile::new(
20387                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20388                agent("codex"),
20389                TransportKind::Pty,
20390                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20391                Arc::new(EmptyEnvironmentResolver),
20392            )
20393            .unwrap()],
20394            materialization,
20395        )
20396        .unwrap();
20397        let (binding, native_profiles, materialization) = node_profile.into_parts();
20398        for native_profile in native_profiles {
20399            runtime.upsert_native_launch_profile(native_profile).unwrap();
20400        }
20401        shared
20402            .environment_profiles
20403            .write()
20404            .unwrap_or_else(|poisoned| poisoned.into_inner())
20405            .insert(binding.id.clone(), binding);
20406        if let Some(materialization) = materialization {
20407            shared
20408                .environment_materialization_profiles
20409                .write()
20410                .unwrap_or_else(|poisoned| poisoned.into_inner())
20411                .insert(profile_id.clone(), materialization);
20412        }
20413
20414        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20415        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20416        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20417        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20418        std::fs::write(
20419            bundle_source.join(".claude-plugin/plugin.json"),
20420            CLAUDE_MANIFEST,
20421        )
20422        .unwrap();
20423        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20424        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20425        let bundle = NodeBundle::new(
20426            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20427            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20428            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20429            &bundle_source,
20430        )
20431        .unwrap();
20432        *shared
20433            .bundle_catalog
20434            .write()
20435            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20436            BundleCatalog::new([bundle.clone()]).unwrap();
20437        (
20438            shared,
20439            runtime,
20440            ResolvedEnvironmentProfileReceipt {
20441                profile_id,
20442                profile_revision,
20443                network_allowlist: None,
20444                browser_profile_id: None,
20445            },
20446            bundle,
20447            root,
20448        )
20449    }
20450
20451    #[cfg(feature = "fixture")]
20452    #[test]
20453    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20454        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20455            let (shared, runtime, receipt, bundle, root) =
20456                codex_bundle_test_shared(home_profile);
20457            let error = shared
20458                .bundle_layout(
20459                    &agent("codex"),
20460                    SessionMode::Pty,
20461                    Some(&receipt),
20462                    &bundle,
20463                )
20464                .unwrap_err();
20465            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20466            assert!(shared
20467                .materializations
20468                .lock()
20469                .unwrap_or_else(|poisoned| poisoned.into_inner())
20470                .is_empty());
20471            let materialization_root = root.join("session-environments");
20472            assert!(!std::fs::read_dir(&materialization_root)
20473                .unwrap()
20474                .filter_map(Result::ok)
20475                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20476            drop(shared);
20477            drop(runtime);
20478            let _ = std::fs::remove_dir_all(root);
20479        }
20480    }
20481
20482    #[cfg(feature = "fixture")]
20483    #[test]
20484    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20485        let (shared, runtime, receipt, bundle, root) =
20486            codex_bundle_test_shared(CodexHomeProfile::Exact);
20487        let address = SessionAddress {
20488            workspace_id: WorkspaceId::new("primary").unwrap(),
20489            session: SessionKey {
20490                instance_id: AgentInstanceId(310),
20491                generation: SessionGeneration(1),
20492            },
20493        };
20494        let (overlay, guard) = shared
20495            .prepare_session_materialization(
20496                &address,
20497                &agent("codex"),
20498                SessionMode::Pty,
20499                Some(&receipt),
20500                Some(&bundle.receipt()),
20501                None,
20502                None,
20503            )
20504            .unwrap()
20505            .unwrap();
20506        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20507        let ownership = shared
20508            .materializations
20509            .lock()
20510            .unwrap_or_else(|poisoned| poisoned.into_inner())
20511            .get(guard.id().unwrap())
20512            .unwrap()
20513            .clone();
20514        let (profile, _, _) = shared
20515            .materialization_profile(
20516                &agent("codex"),
20517                SessionMode::Pty,
20518                Some(&receipt),
20519                Some(&bundle.receipt()),
20520                None,
20521            )
20522            .unwrap()
20523            .unwrap();
20524        let environment = shared
20525            .session_environment_materializer
20526            .as_ref()
20527            .unwrap()
20528            .resolve_environment(&ownership, &profile)
20529            .unwrap();
20530        assert!(environment.iter().any(|mutation| {
20531            mutation.key == OsString::from("CODEX_HOME")
20532                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20533        }));
20534        assert_eq!(
20535            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20536                .unwrap(),
20537            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20538        );
20539        assert!(std::fs::read_dir(ownership.bundle_root())
20540            .unwrap()
20541            .next()
20542            .is_none());
20543        drop(guard);
20544        drop(shared);
20545        drop(runtime);
20546        let _ = std::fs::remove_dir_all(root);
20547    }
20548
20549    #[cfg(feature = "fixture")]
20550    #[test]
20551    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20552        let (shared, runtime, receipt, bundle, root) =
20553            codex_bundle_test_shared(CodexHomeProfile::Exact);
20554        let bundle_receipt = bundle.receipt();
20555        let address = SessionAddress {
20556            workspace_id: WorkspaceId::new("primary").unwrap(),
20557            session: SessionKey {
20558                instance_id: AgentInstanceId(311),
20559                generation: SessionGeneration(1),
20560            },
20561        };
20562        let (_overlay, guard) = shared
20563            .prepare_session_materialization(
20564                &address,
20565                &agent("codex"),
20566                SessionMode::Pty,
20567                Some(&receipt),
20568                Some(&bundle_receipt),
20569                None,
20570                None,
20571            )
20572            .unwrap()
20573            .unwrap();
20574        let materialization_id = guard.id().unwrap().clone();
20575        let skill_path = shared
20576            .materializations
20577            .lock()
20578            .unwrap_or_else(|poisoned| poisoned.into_inner())
20579            .get(&materialization_id)
20580            .unwrap()
20581            .provider_home()
20582            .join("skills/review-code/SKILL.md");
20583        let record_id = shared
20584            .bind_spawn_session_with_materialization(
20585                &address,
20586                agent("codex"),
20587                SessionMode::Pty,
20588                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20589                SpawnRecordPolicy::ProviderIdentityOnly,
20590                Some(receipt.clone()),
20591                Some(bundle_receipt.clone()),
20592                None,
20593                Some(materialization_id.clone()),
20594            )
20595            .unwrap()
20596            .unwrap();
20597        guard.retain();
20598        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20599
20600        let error = match shared.resolve_record_materialization(
20601                &record_id,
20602                &agent("codex"),
20603                SessionMode::Pty,
20604                Some(&receipt),
20605                Some(&bundle_receipt),
20606                None,
20607            ) {
20608                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20609                Err(error) => error,
20610            };
20611        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20612        assert_eq!(
20613            shared
20614                .materializations
20615                .lock()
20616                .unwrap_or_else(|poisoned| poisoned.into_inner())
20617                .get(&materialization_id)
20618                .unwrap()
20619                .state(),
20620            MaterializationState::RecoveryRequired,
20621        );
20622        drop(shared);
20623        drop(runtime);
20624        let _ = std::fs::remove_dir_all(root);
20625    }
20626
20627    fn bind_record_owned_materialization_fixture(
20628        shared: &NodeShared,
20629        receipt: &ResolvedEnvironmentProfileReceipt,
20630        instance_id: u64,
20631    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20632        let address = SessionAddress {
20633            workspace_id: WorkspaceId::new("primary").unwrap(),
20634            session: SessionKey {
20635                instance_id: AgentInstanceId(instance_id),
20636                generation: SessionGeneration(1),
20637            },
20638        };
20639        let (overlay, materialization_guard) = shared
20640            .prepare_session_materialization(
20641                &address,
20642                &agent("claude"),
20643                SessionMode::Pty,
20644                Some(receipt),
20645                None,
20646                None,
20647                None,
20648            )
20649            .unwrap()
20650            .unwrap();
20651        let materialization_id = materialization_guard.id().unwrap().clone();
20652        let materialization_root = shared
20653            .materializations
20654            .lock()
20655            .unwrap_or_else(|poisoned| poisoned.into_inner())
20656            .get(&materialization_id)
20657            .unwrap()
20658            .root()
20659            .to_path_buf();
20660        let selection = shared
20661            .select_environment_profile(
20662                address.session.instance_id,
20663                &agent("claude"),
20664                SessionMode::Pty,
20665                Some(receipt),
20666            )
20667            .unwrap()
20668            .unwrap();
20669        assert!(shared
20670            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20671            .unwrap()
20672            .is_none());
20673        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20674        let record_id = shared
20675            .bind_spawn_session_with_materialization(
20676                &address,
20677                agent("claude"),
20678                SessionMode::Pty,
20679                policy,
20680                SpawnRecordPolicy::ProviderIdentityOnly,
20681                Some(receipt.clone()),
20682                None,
20683                None,
20684                Some(materialization_id.clone()),
20685            )
20686            .unwrap()
20687            .unwrap();
20688        selection.retain();
20689        materialization_guard.retain();
20690        (address, record_id, materialization_id, materialization_root)
20691    }
20692
20693    #[test]
20694    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20695        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20696        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20697            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20698        }));
20699        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20700        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20701        let mut spec = spawn_spec_fixture();
20702        spec.expected_profile_revision =
20703            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20704        assert!(matches!(
20705            &spec.overrides.environment_profile_id,
20706            crate::protocol::SpawnOverride::Inherit,
20707        ));
20708        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20709            .requires_child_environment_profile_capability());
20710        assert!(request_requires_child_environment_profile(
20711            &shared,
20712            &NodeRequest::SpawnSpec { spec: spec.clone() },
20713        ));
20714
20715        let address = terminal_address(1);
20716        shared.bind_session_with_policy(
20717            &address,
20718            ProviderRuntimePolicy::raw_pty(),
20719            Some(environment_profile.clone()),
20720        );
20721        assert!(request_requires_child_environment_profile(
20722            &shared,
20723            &NodeRequest::Input {
20724                session: address.clone(),
20725                text: "x".to_owned(),
20726            },
20727        ));
20728
20729        let mut record = record("claude", "profile-record");
20730        record.active_session = Some(address.clone());
20731        record.environment_profile = Some(environment_profile.clone());
20732        let record_id = record.record_id.clone();
20733        shared.insert_record(record.clone()).unwrap();
20734        assert!(request_requires_child_environment_profile(
20735            &shared,
20736            &NodeRequest::ResumeSessionRecord {
20737                record_id,
20738                terminal_size: gate4agent_types::TerminalSize {
20739                    rows: 24,
20740                    columns: 80,
20741                },
20742                initial_prompt: None,
20743            },
20744        ));
20745
20746        let snapshot = snapshot_for_wire(
20747            &shared, false, true, true, true, false, false, false, false, true,
20748        );
20749        assert_eq!(snapshot.session_records.len(), 1);
20750        assert!(snapshot.session_records[0].environment_profile.is_none());
20751        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20752            sequence: 1,
20753            event: NodeEvent::SessionRecordUpserted { record },
20754        });
20755        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20756            panic!("record projection changed the event kind");
20757        };
20758        assert!(record.environment_profile.is_none());
20759
20760        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20761        let mut reply = ResponseEnvelope {
20762            request_id: 1,
20763            result: Ok(NodeResponse::SpawnSpecAccepted {
20764                receipt: resolved.receipt_with_environment(
20765                    shared.incarnation_id,
20766                    address,
20767                    Some(environment_profile),
20768                ),
20769            }),
20770        };
20771        project_response_without_child_environment_profile(&mut reply);
20772        assert_eq!(
20773            reply.result.unwrap_err().code,
20774            NodeFailureCode::UnsupportedCapability,
20775        );
20776    }
20777
20778    #[test]
20779    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20780        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20781        let snapshot = shared.snapshot();
20782        let profiles = snapshot
20783            .launch_inventory
20784            .as_ref()
20785            .and_then(|inventory| inventory.spawn_profiles.as_ref())
20786            .unwrap();
20787        assert_eq!(profiles.len(), 1);
20788        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20789        assert!(snapshot.requires_child_environment_profile_capability());
20790
20791        let mut legacy = snapshot.clone();
20792        clear_snapshot_child_environment_profiles(&mut legacy);
20793        assert!(legacy
20794            .launch_inventory
20795            .as_ref()
20796            .unwrap()
20797            .spawn_profiles
20798            .as_ref()
20799            .unwrap()[0]
20800            .environment_profile
20801            .is_none());
20802        assert!(!legacy.requires_child_environment_profile_capability());
20803
20804        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20805        let bindings = shared
20806            .environment_profiles
20807            .read()
20808            .unwrap_or_else(|poisoned| poisoned.into_inner())
20809            .clone();
20810        let mut provider_mismatch = bindings.clone();
20811        provider_mismatch
20812            .values_mut()
20813            .next()
20814            .unwrap()
20815            .provider = agent("codex");
20816        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20817        let mut mode_mismatch = profile;
20818        mode_mismatch.mode = SessionMode::Inline;
20819        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20820
20821        shared
20822            .environment_profiles
20823            .write()
20824            .unwrap_or_else(|poisoned| poisoned.into_inner())
20825            .clear();
20826        assert!(shared
20827            .snapshot()
20828            .launch_inventory
20829            .unwrap()
20830            .spawn_profiles
20831            .unwrap()
20832            .is_empty());
20833    }
20834
20835    #[test]
20836    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20837        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20838            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20839        }));
20840        let shared = terminal_test_shared();
20841        let bundle = ResolvedBundleReceipt {
20842            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20843            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20844            digest: crate::protocol::SpawnBundleDigest::new(format!(
20845                "sha256:{}",
20846                "0".repeat(64),
20847            ))
20848            .unwrap(),
20849        };
20850        let mut bundled = record("claude", "bundle-record");
20851        bundled.bundle = Some(bundle.clone());
20852        let record_id = bundled.record_id.clone();
20853        shared.insert_record(bundled.clone()).unwrap();
20854        assert!(request_requires_session_bundle(
20855            &shared,
20856            &NodeRequest::ForgetSessionRecord {
20857                record_id: record_id.clone(),
20858            },
20859        ));
20860
20861        let snapshot = snapshot_for_wire(
20862            &shared, false, true, true, true, true, false, false, false, true,
20863        );
20864        assert!(snapshot.session_records[0].bundle.is_none());
20865        let launch = snapshot.launch_inventory.as_ref().unwrap();
20866        assert!(launch.spawn_profiles.is_some());
20867        assert!(launch.bundles.is_none());
20868        let event = project_event_without_session_bundle(NodeEventEnvelope {
20869            sequence: 1,
20870            event: NodeEvent::SessionRecordUpserted { record: bundled },
20871        });
20872        let NodeEvent::SessionRecordUpserted { record } = event.event else {
20873            panic!("record projection changed the event kind");
20874        };
20875        assert!(record.bundle.is_none());
20876
20877        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20878        let mut reply = ResponseEnvelope {
20879            request_id: 1,
20880            result: Ok(NodeResponse::SpawnSpecAccepted {
20881                receipt: resolved.receipt_with_materialization(
20882                    shared.incarnation_id,
20883                    terminal_address(17),
20884                    None,
20885                    Some(bundle),
20886                    None,
20887                ),
20888            }),
20889        };
20890        project_response_without_session_bundle(&mut reply);
20891        assert_eq!(
20892            reply.result.unwrap_err().code,
20893            NodeFailureCode::UnsupportedCapability,
20894        );
20895    }
20896
20897    #[test]
20898    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20899        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20900            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20901        }));
20902        let context_id = SpawnContextId::new("context-review").unwrap();
20903        let context = ResolvedContextPackReceipt {
20904            id: context_id.clone(),
20905            digest: crate::protocol::SpawnContextDigest::new(format!(
20906                "sha256:{}",
20907                "c".repeat(64),
20908            ))
20909            .unwrap(),
20910            lineage: ContextPackLineageReceipt {
20911                source_node_id: NodeId::new("node-source").unwrap(),
20912                source_session: terminal_address(1),
20913                source_provider: agent("kimi"),
20914            },
20915            source_message_count: 2,
20916            retained_message_count: 2,
20917            byte_len: 256,
20918            truncated: false,
20919        };
20920        assert!(context.is_valid());
20921        let mut bound_record = record("codex", "context-record");
20922        bound_record.context_id = Some(context_id);
20923        bound_record.context = Some(context.clone());
20924        let session = gate4agent_types::SessionSnapshot {
20925            instance_id: AgentInstanceId(91),
20926            agent_id: agent("kimi"),
20927            transport: TransportKind::Pty,
20928            generation: SessionGeneration(3),
20929            status: SessionStatus::Running,
20930            pending_operation: None,
20931            pending_input: None,
20932            process_id: Some(9001),
20933            terminal_size: None,
20934            terminal_frame: None,
20935            terminal_stale: None,
20936            session_options: None,
20937            capabilities: gate4agent_types::CapabilitySnapshot::default(),
20938            history: gate4agent_types::HistorySnapshot {
20939                pending: None,
20940                candidates: vec![HistoryCandidateSummary {
20941                    id: "candidate-opaque".to_owned(),
20942                    session_id_hint: "session-hint".to_owned(),
20943                    modified_at_unix_ms: Some(1),
20944                }],
20945                loaded_candidate_id: Some("candidate-opaque".to_owned()),
20946                loaded: Some(HistorySessionRecord {
20947                    session_id: "private-vendor-session".to_owned(),
20948                    title: Some("private title".to_owned()),
20949                    cwd: Some(r"C:\private\history-root".to_owned()),
20950                    model: Some("private-model".to_owned()),
20951                    message_count: 2,
20952                    completed_turn_count: None,
20953                    total_tokens: 19,
20954                    messages: vec![
20955                        gate4agent_types::HistoryMessageRecord {
20956                            role: gate4agent_types::HistoryMessageRole::User,
20957                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20958                        },
20959                        gate4agent_types::HistoryMessageRecord {
20960                            role: gate4agent_types::HistoryMessageRole::Assistant,
20961                            text: "private answer".to_owned(),
20962                        },
20963                    ],
20964                }),
20965                last_error: None,
20966            },
20967            resume: gate4agent_types::ResumeSnapshot::default(),
20968            foreground: gate4agent_types::ForegroundSnapshot::default(),
20969            provider: gate4agent_types::ProviderSnapshot::default(),
20970            screen_state: gate4agent_types::PtyScreenState::default(),
20971        };
20972        let original = NodeSnapshot {
20973            node_id: NodeId::new("node-terminal-test").unwrap(),
20974            enabled_providers: vec![agent("kimi"), agent("codex")],
20975            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20976            workspaces: vec![WorkspaceSnapshot {
20977                workspace_id: WorkspaceId::new("primary").unwrap(),
20978                canonical_root: opaque_windows_path(
20979                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20980                ),
20981                sessions: vec![session],
20982                worktree_service_mode: None,
20983                managed_worktree_profiles: None,
20984            }],
20985            session_records: vec![bound_record],
20986            managed_worktrees: Vec::new(),
20987            launch_inventory: None,
20988            agent_progress: Vec::new(),
20989        };
20990
20991        let mut negotiated = original.clone();
20992        project_snapshot_history_for_wire(&mut negotiated, true);
20993        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
20994        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
20995        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
20996        assert!(!serde_json::to_string(&negotiated)
20997            .unwrap()
20998            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
20999
21000        let mut legacy = original;
21001        project_snapshot_history_for_wire(&mut legacy, false);
21002        clear_snapshot_context_packs(&mut legacy);
21003        assert_eq!(
21004            legacy.workspaces[0].sessions[0].history,
21005            gate4agent_types::HistorySnapshot::default(),
21006        );
21007        assert!(legacy.session_records[0].context_id.is_none());
21008        assert!(legacy.session_records[0].context.is_none());
21009        let encoded = serde_json::to_string(&legacy).unwrap();
21010        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21011        assert!(!encoded.contains("context-review"));
21012
21013        let mut reply = ResponseEnvelope {
21014            request_id: 7,
21015            result: Ok(NodeResponse::ContextPackExported { context }),
21016        };
21017        project_response_without_context_pack(&mut reply);
21018        assert_eq!(
21019            reply.result.unwrap_err().code,
21020            NodeFailureCode::UnsupportedCapability,
21021        );
21022    }
21023
21024    #[test]
21025    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21026        let shared = terminal_test_shared();
21027        let history = HistorySessionRecord {
21028            session_id: "fixture-history".to_owned(),
21029            title: None,
21030            cwd: None,
21031            model: None,
21032            message_count: 1,
21033            completed_turn_count: None,
21034            total_tokens: 0,
21035            messages: vec![gate4agent_types::HistoryMessageRecord {
21036                role: gate4agent_types::HistoryMessageRole::User,
21037                text: "bounded handoff".to_owned(),
21038            }],
21039        };
21040        let pack = NodeContextPack::export(
21041            ContextPackLineageReceipt {
21042                source_node_id: shared.node_id.clone(),
21043                source_session: terminal_address(1),
21044                source_provider: agent("claude"),
21045            },
21046            &history,
21047        )
21048        .unwrap();
21049        let context = shared
21050            .context_catalog
21051            .write()
21052            .unwrap_or_else(|poisoned| poisoned.into_inner())
21053            .insert(pack)
21054            .unwrap();
21055        let mut retained = record("codex", "context-retained-record");
21056        retained.context_id = Some(context.id.clone());
21057        retained.context = Some(context.clone());
21058        let record_id = retained.record_id.clone();
21059        shared.insert_record(retained).unwrap();
21060        assert_eq!(
21061            shared.forget_context_pack(&context.id).unwrap_err().code,
21062            NodeFailureCode::ContextPackBusy,
21063        );
21064        assert!(shared
21065            .context_catalog
21066            .read()
21067            .unwrap_or_else(|poisoned| poisoned.into_inner())
21068            .get(&context.id)
21069            .is_some());
21070        shared
21071            .session_records
21072            .lock()
21073            .unwrap_or_else(|poisoned| poisoned.into_inner())
21074            .records
21075            .remove(&record_id);
21076
21077        let mut spec = spawn_spec_fixture();
21078        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21079            value: context.id.clone(),
21080        };
21081        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21082        let receipt = resolved.receipt_with_materialization(
21083            shared.incarnation_id,
21084            terminal_address(2),
21085            None,
21086            None,
21087            Some(context.clone()),
21088        );
21089        assert!(receipt.context_binding_is_valid());
21090        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21091
21092        shared.forget_context_pack(&context.id).unwrap();
21093        assert!(shared
21094            .context_catalog
21095            .read()
21096            .unwrap_or_else(|poisoned| poisoned.into_inner())
21097            .get(&context.id)
21098            .is_none());
21099        assert!(shared
21100            .replay_spawn_spec(&spec, Instant::now())
21101            .unwrap()
21102            .is_none());
21103    }
21104
21105    #[test]
21106    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21107        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21108        let address = terminal_address(1);
21109        let selection = shared
21110            .select_environment_profile(
21111                address.session.instance_id,
21112                &agent("claude"),
21113                SessionMode::Pty,
21114                Some(&environment_profile),
21115            )
21116            .unwrap()
21117            .unwrap();
21118        shared.bind_session_with_policy(
21119            &address,
21120            ProviderRuntimePolicy::raw_pty(),
21121            Some(environment_profile.clone()),
21122        );
21123        selection.retain();
21124        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21125        assert!(matches!(
21126            runtime.remove_native_launch_profile(&native_id),
21127            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21128        ));
21129        assert!(shared.remove_binding(&address).is_some());
21130        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21131
21132        let mismatched = ResolvedEnvironmentProfileReceipt {
21133            profile_id: environment_profile.profile_id,
21134            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21135                "local-claude-r2",
21136            )
21137            .unwrap(),
21138            network_allowlist: None,
21139            browser_profile_id: None,
21140        };
21141        let error = match shared.select_environment_profile(
21142                AgentInstanceId(42),
21143                &agent("claude"),
21144                SessionMode::Pty,
21145                Some(&mismatched),
21146            ) {
21147            Ok(_) => panic!("mismatched environment profile revision was selected"),
21148            Err(error) => error,
21149        };
21150        assert_eq!(
21151            error.code,
21152            NodeFailureCode::EnvironmentProfileBindingMismatch,
21153        );
21154        assert!(!shared
21155            .session_bindings
21156            .lock()
21157            .unwrap_or_else(|poisoned| poisoned.into_inner())
21158            .contains_key(&AgentInstanceId(42)));
21159    }
21160
21161    #[test]
21162    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21163        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21164        let address = terminal_address(1);
21165        let selection = shared
21166            .select_environment_profile(
21167                address.session.instance_id,
21168                &agent("claude"),
21169                SessionMode::Pty,
21170                Some(&environment_profile),
21171            )
21172            .unwrap()
21173            .unwrap();
21174        let overlay = NativeInstanceLaunchOverlay::new(
21175            agent("claude"),
21176            TransportKind::Pty,
21177            vec![EnvMutation {
21178                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21179                value: Some(OsString::from("fixture-secret")),
21180            }],
21181            vec![
21182                OsString::from("--plugin-dir"),
21183                std::env::current_dir().unwrap().into_os_string(),
21184            ],
21185        )
21186        .unwrap();
21187        let instance_overlay = shared
21188            .install_prepared_launch_overlay(
21189                address.session.instance_id,
21190                PreparedNativeLaunchOverlay::Instance(overlay),
21191            )
21192            .unwrap()
21193            .unwrap();
21194        let bundle = ResolvedBundleReceipt {
21195            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21196            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21197            digest: crate::protocol::SpawnBundleDigest::new(format!(
21198                "sha256:{}",
21199                "0".repeat(64),
21200            ))
21201            .unwrap(),
21202        };
21203        shared.bind_session_with_materialization(
21204            &address,
21205            ProviderRuntimePolicy::raw_pty(),
21206            Some(environment_profile),
21207            Some(bundle),
21208            None,
21209            None,
21210        );
21211        selection.retain();
21212        instance_overlay.retain();
21213
21214        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21215        assert!(matches!(
21216            runtime.remove_native_launch_profile(&native_id),
21217            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21218        ));
21219        assert!(shared.remove_binding(&address).is_some());
21220        assert!(!shared
21221            .native_launch_profile_control
21222            .as_ref()
21223            .unwrap()
21224            .clear_native_instance_launch_overlay(address.session.instance_id));
21225        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21226    }
21227
21228    #[test]
21229    fn session_environment_materialization_failure_is_pre_child_and_durable() {
21230        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21231        let address = SessionAddress {
21232            workspace_id: WorkspaceId::new("primary").unwrap(),
21233            session: SessionKey {
21234                instance_id: AgentInstanceId(201),
21235                generation: SessionGeneration(1),
21236            },
21237        };
21238        let error = match shared.prepare_session_materialization(
21239            &address,
21240            &agent("claude"),
21241            SessionMode::Pty,
21242            Some(&receipt),
21243            None,
21244            None,
21245            None,
21246        ) {
21247            Ok(_) => panic!("denied local reference unexpectedly materialized"),
21248            Err(error) => error,
21249        };
21250        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21251        assert!(shared.handle.snapshot().sessions.is_empty());
21252        assert!(shared
21253            .session_bindings
21254            .lock()
21255            .unwrap_or_else(|poisoned| poisoned.into_inner())
21256            .is_empty());
21257        assert!(shared
21258            .materializations
21259            .lock()
21260            .unwrap_or_else(|poisoned| poisoned.into_inner())
21261            .is_empty());
21262        drop(shared);
21263        drop(runtime);
21264        std::fs::remove_dir_all(root).unwrap();
21265    }
21266
21267    #[test]
21268    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21269        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21270        let address = SessionAddress {
21271            workspace_id: WorkspaceId::new("primary").unwrap(),
21272            session: SessionKey {
21273                instance_id: AgentInstanceId(202),
21274                generation: SessionGeneration(1),
21275            },
21276        };
21277        let (overlay, materialization_guard) = shared
21278            .prepare_session_materialization(
21279                &address,
21280                &agent("claude"),
21281                SessionMode::Pty,
21282                Some(&receipt),
21283                None,
21284                None,
21285                None,
21286            )
21287            .unwrap()
21288            .unwrap();
21289        let materialization_id = materialization_guard.id().unwrap().clone();
21290        let materialization_root = shared
21291            .materializations
21292            .lock()
21293            .unwrap_or_else(|poisoned| poisoned.into_inner())
21294            .get(&materialization_id)
21295            .unwrap()
21296            .root()
21297            .to_path_buf();
21298        let selection = shared
21299            .select_environment_profile(
21300                address.session.instance_id,
21301                &agent("claude"),
21302                SessionMode::Pty,
21303                Some(&receipt),
21304            )
21305            .unwrap()
21306            .unwrap();
21307        assert!(shared
21308            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21309            .unwrap()
21310            .is_none());
21311        shared.bind_session_with_materialization(
21312            &address,
21313            ProviderRuntimePolicy::raw_pty(),
21314            Some(receipt),
21315            None,
21316            None,
21317            Some(materialization_id),
21318        );
21319        selection.retain();
21320        materialization_guard.retain();
21321
21322        assert!(materialization_root.is_dir());
21323        assert!(shared
21324            .session_bindings
21325            .lock()
21326            .unwrap_or_else(|poisoned| poisoned.into_inner())
21327            .contains_key(&address.session.instance_id));
21328
21329        let removed = shared.remove_binding(&address).unwrap();
21330        shared
21331            .cleanup_session_owned_materialization(&address, &removed)
21332            .unwrap();
21333        assert!(!materialization_root.exists());
21334        assert!(shared
21335            .materializations
21336            .lock()
21337            .unwrap_or_else(|poisoned| poisoned.into_inner())
21338            .is_empty());
21339        drop(shared);
21340        drop(runtime);
21341        std::fs::remove_dir_all(root).unwrap();
21342    }
21343
21344    #[tokio::test]
21345    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21346        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21347        let address = SessionAddress {
21348            workspace_id: WorkspaceId::new("primary").unwrap(),
21349            session: SessionKey {
21350                instance_id: AgentInstanceId(203),
21351                generation: SessionGeneration(1),
21352            },
21353        };
21354        let (overlay, materialization_guard) = shared
21355            .prepare_session_materialization(
21356                &address,
21357                &agent("claude"),
21358                SessionMode::Pty,
21359                Some(&receipt),
21360                None,
21361                None,
21362                None,
21363            )
21364            .unwrap()
21365            .unwrap();
21366        let materialization_id = materialization_guard.id().unwrap().clone();
21367        let materialization_root = shared
21368            .materializations
21369            .lock()
21370            .unwrap_or_else(|poisoned| poisoned.into_inner())
21371            .get(&materialization_id)
21372            .unwrap()
21373            .root()
21374            .to_path_buf();
21375        let selection = shared
21376            .select_environment_profile(
21377                address.session.instance_id,
21378                &agent("claude"),
21379                SessionMode::Pty,
21380                Some(&receipt),
21381            )
21382            .unwrap()
21383            .unwrap();
21384        assert!(shared
21385            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21386            .unwrap()
21387            .is_none());
21388        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21389        let record_id = shared
21390            .bind_spawn_session_with_materialization(
21391                &address,
21392                agent("claude"),
21393                SessionMode::Pty,
21394                policy,
21395                SpawnRecordPolicy::ProviderIdentityOnly,
21396                Some(receipt.clone()),
21397                None,
21398                None,
21399                Some(materialization_id.clone()),
21400            )
21401            .unwrap()
21402            .unwrap();
21403        selection.retain();
21404        materialization_guard.retain();
21405        assert_eq!(
21406            shared
21407                .materializations
21408                .lock()
21409                .unwrap_or_else(|poisoned| poisoned.into_inner())
21410                .get(&materialization_id)
21411                .unwrap()
21412                .owner(),
21413            &MaterializationOwner::Record {
21414                record_id: record_id.clone(),
21415            },
21416        );
21417
21418        let removed = shared.remove_binding(&address).unwrap();
21419        shared
21420            .cleanup_session_owned_materialization(&address, &removed)
21421            .unwrap();
21422        assert!(materialization_root.is_dir());
21423        assert!(shared
21424            .resolve_record_materialization(
21425                &record_id,
21426                &agent("claude"),
21427                SessionMode::Pty,
21428                Some(&receipt),
21429                None,
21430                None,
21431            )
21432            .unwrap()
21433            .is_some());
21434        {
21435            let mut records = shared
21436                .session_records
21437                .lock()
21438                .unwrap_or_else(|poisoned| poisoned.into_inner());
21439            let record = records.records.get_mut(&record_id).unwrap();
21440            record.active_session = None;
21441            record.state = ManagedSessionState::Unavailable;
21442        }
21443        shared.forget_session_record(&record_id).await.unwrap();
21444        assert!(!materialization_root.exists());
21445        assert!(shared
21446            .materializations
21447            .lock()
21448            .unwrap_or_else(|poisoned| poisoned.into_inner())
21449            .is_empty());
21450        drop(shared);
21451        drop(runtime);
21452        std::fs::remove_dir_all(root).unwrap();
21453    }
21454
21455    #[test]
21456    fn record_materialization_revalidation_failure_persists_recovery_required() {
21457        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21458        let state_path = root.join("node-state-v6.json");
21459        shared.state_path = Some(state_path.clone());
21460        let (_address, _record_id, materialization_id, materialization_root) =
21461            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21462        std::fs::write(
21463            materialization_root.join(".gate4agent-materialization-owner"),
21464            b"tampered",
21465        )
21466        .unwrap();
21467
21468        let error = match shared.resolve_record_materialization(
21469            &_record_id,
21470            &agent("claude"),
21471            SessionMode::Pty,
21472            Some(&receipt),
21473            None,
21474            None,
21475        ) {
21476            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21477            Err(error) => error,
21478        };
21479        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21480        assert_eq!(
21481            shared
21482                .materializations
21483                .lock()
21484                .unwrap_or_else(|poisoned| poisoned.into_inner())
21485                .get(&materialization_id)
21486                .unwrap()
21487                .state(),
21488            MaterializationState::RecoveryRequired,
21489        );
21490        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21491        assert_eq!(loaded.materializations.len(), 1);
21492        assert_eq!(
21493            loaded.materializations[0].state(),
21494            MaterializationState::RecoveryRequired,
21495        );
21496        drop(shared);
21497        drop(runtime);
21498        std::fs::remove_dir_all(root).unwrap();
21499    }
21500
21501    #[test]
21502    fn transient_record_materialization_resolution_failure_remains_ready() {
21503        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21504        let (_address, record_id, materialization_id, _materialization_root) =
21505            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21506        deny.store(true, Ordering::Release);
21507
21508        let error = match shared.resolve_record_materialization(
21509            &record_id,
21510            &agent("claude"),
21511            SessionMode::Pty,
21512            Some(&receipt),
21513            None,
21514            None,
21515        ) {
21516            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21517            Err(error) => error,
21518        };
21519        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21520        assert_eq!(
21521            shared
21522                .materializations
21523                .lock()
21524                .unwrap_or_else(|poisoned| poisoned.into_inner())
21525                .get(&materialization_id)
21526                .unwrap()
21527                .state(),
21528            MaterializationState::Ready,
21529        );
21530        drop(shared);
21531        drop(runtime);
21532        std::fs::remove_dir_all(root).unwrap();
21533    }
21534
21535    #[test]
21536    fn startup_missing_record_materialization_is_not_resumable() {
21537        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21538        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21539        shared
21540            .insert_record(ManagedSessionRecord {
21541                record_id: record_id.clone(),
21542                display_name: "missing materialization".to_owned(),
21543                provider: agent("claude"),
21544                mode: SessionMode::Pty,
21545                state: ManagedSessionState::Dormant,
21546                workspace_id: WorkspaceId::new("primary").unwrap(),
21547                canonical_root: opaque_windows_path(
21548                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21549                ),
21550                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21551                    key: gate4agent_types::ProviderSessionKey::SessionId,
21552                    id: "provider-session-missing-materialization".to_owned(),
21553                    transcript_path: None,
21554                }),
21555                active_session: None,
21556                environment_profile: Some(receipt),
21557                bundle: None,
21558                context_id: None,
21559                context: None,
21560                exported_context: None,
21561                task_binding: None,
21562                created_at_unix_ms: 1,
21563                updated_at_unix_ms: 1,
21564                last_error: None,
21565            })
21566            .unwrap();
21567
21568        shared.reconcile_materializations();
21569        let record = shared.record(&record_id).unwrap();
21570        assert_eq!(record.state, ManagedSessionState::Unavailable);
21571        assert!(record.provider_session.is_none());
21572        assert_eq!(
21573            record.last_error.as_deref(),
21574            Some("environment-profile-unavailable"),
21575        );
21576        drop(shared);
21577        drop(runtime);
21578        std::fs::remove_dir_all(root).unwrap();
21579    }
21580
21581    #[tokio::test]
21582    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21583        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21584        let lease = install_managed_lease(&shared);
21585        let address = SessionAddress {
21586            workspace_id: WorkspaceId::new("primary").unwrap(),
21587            session: SessionKey {
21588                instance_id: AgentInstanceId(204),
21589                generation: SessionGeneration(1),
21590            },
21591        };
21592        let (overlay, materialization_guard) = shared
21593            .prepare_session_materialization(
21594                &address,
21595                &agent("claude"),
21596                SessionMode::Pty,
21597                Some(&receipt),
21598                None,
21599                None,
21600                Some(lease.lease_id.clone()),
21601            )
21602            .unwrap()
21603            .unwrap();
21604        let materialization_id = materialization_guard.id().unwrap().clone();
21605        let materialization_root = shared
21606            .materializations
21607            .lock()
21608            .unwrap_or_else(|poisoned| poisoned.into_inner())
21609            .get(&materialization_id)
21610            .unwrap()
21611            .root()
21612            .to_path_buf();
21613        let selection = shared
21614            .select_environment_profile(
21615                address.session.instance_id,
21616                &agent("claude"),
21617                SessionMode::Pty,
21618                Some(&receipt),
21619            )
21620            .unwrap()
21621            .unwrap();
21622        assert!(shared
21623            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21624            .unwrap()
21625            .is_none());
21626        shared.bind_session_with_materialization(
21627            &address,
21628            ProviderRuntimePolicy::raw_pty(),
21629            Some(receipt),
21630            None,
21631            None,
21632            Some(materialization_id),
21633        );
21634        selection.retain();
21635        materialization_guard.retain();
21636
21637        let blocked = shared
21638            .cleanup_managed_worktree(&lease.lease_id, false)
21639            .await
21640            .unwrap_err();
21641        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21642        assert!(materialization_root.is_dir());
21643        let removed = shared.remove_binding(&address).unwrap();
21644        shared
21645            .cleanup_session_owned_materialization(&address, &removed)
21646            .unwrap();
21647        assert!(!materialization_root.exists());
21648        drop(shared);
21649        drop(runtime);
21650        std::fs::remove_dir_all(root).unwrap();
21651    }
21652
21653    #[tokio::test]
21654    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21655        let git_root = temporary_workspace_root("raw-managed-inventory");
21656        let source = git_root.join("source");
21657        let allocation = git_root.join("allocation");
21658        std::fs::create_dir_all(&source).unwrap();
21659        std::fs::create_dir_all(&allocation).unwrap();
21660        run_test_git(&source, &["init"]);
21661        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21662        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21663        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21664        run_test_git(&source, &["add", "seed.txt"]);
21665        run_test_git(&source, &["commit", "-m", "seed"]);
21666        let profile = ManagedWorktreeProfile::new(
21667            WorktreeProfileId::new("default").unwrap(),
21668            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21669            &allocation,
21670            "gate4agent",
21671            "HEAD",
21672            ManagedWorktreeRetention::RemoveWhenReleased,
21673        )
21674        .unwrap();
21675        let workspace = WorkspaceConfig::new(
21676            WorkspaceId::new("primary").unwrap(),
21677            &source,
21678        )
21679        .unwrap()
21680        .with_worktree_service_mode(WorktreeServiceMode::Managed)
21681        .with_managed_worktree_profile(profile.clone())
21682        .unwrap();
21683        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21684            materialization_test_shared_with_workspace(false, workspace);
21685        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21686        let source_text = source.to_string_lossy().into_owned();
21687        let target_text = target.to_string_lossy().into_owned();
21688        let created = create_git_worktree(
21689            &source_text,
21690            &target_text,
21691            "gate4agent/mw-raw-inventory",
21692            Some("HEAD"),
21693        )
21694        .await
21695        .unwrap();
21696        let lease = ManagedWorktreeLeaseRecord {
21697            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21698            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21699            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21700            profile_id: profile.profile_id().clone(),
21701            profile_revision: profile.revision().clone(),
21702            target_root: created.path.clone(),
21703            branch: "gate4agent/mw-raw-inventory".to_owned(),
21704            base_commit: created.head.clone(),
21705            expected_head: Some(created.head.clone()),
21706            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21707            state: ManagedWorktreeLeaseState::Ready,
21708            session_holders: Vec::new(),
21709            record_holders: Vec::new(),
21710            cleanup_failure: None,
21711            created_at_unix_ms: 1,
21712            updated_at_unix_ms: 1,
21713        };
21714        shared
21715            .workspaces
21716            .write()
21717            .unwrap_or_else(|poisoned| poisoned.into_inner())
21718            .insert(lease.workspace_id.clone(), created.path.clone());
21719        *shared
21720            .managed_worktrees
21721            .lock()
21722            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21723            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21724        let address = SessionAddress {
21725            workspace_id: lease.workspace_id.clone(),
21726            session: SessionKey {
21727                instance_id: AgentInstanceId(205),
21728                generation: SessionGeneration(1),
21729            },
21730        };
21731        let (overlay, materialization_guard) = shared
21732            .prepare_session_materialization(
21733                &address,
21734                &agent("claude"),
21735                SessionMode::Pty,
21736                Some(&receipt),
21737                None,
21738                None,
21739                Some(lease.lease_id.clone()),
21740            )
21741            .unwrap()
21742            .unwrap();
21743        let materialization_id = materialization_guard.id().unwrap().clone();
21744        let materialization_root = shared
21745            .materializations
21746            .lock()
21747            .unwrap_or_else(|poisoned| poisoned.into_inner())
21748            .get(&materialization_id)
21749            .unwrap()
21750            .root()
21751            .to_path_buf();
21752        let selection = shared
21753            .select_environment_profile(
21754                address.session.instance_id,
21755                &agent("claude"),
21756                SessionMode::Pty,
21757                Some(&receipt),
21758            )
21759            .unwrap()
21760            .unwrap();
21761        assert!(shared
21762            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21763            .unwrap()
21764            .is_none());
21765        let record_id = shared
21766            .bind_spawn_session_with_materialization(
21767                &address,
21768                agent("claude"),
21769                SessionMode::Pty,
21770                ProviderRuntimePolicy::raw_pty(),
21771                SpawnRecordPolicy::Always,
21772                Some(receipt.clone()),
21773                None,
21774                None,
21775                Some(materialization_id.clone()),
21776            )
21777            .unwrap()
21778            .unwrap();
21779        selection.retain();
21780        materialization_guard.retain();
21781        let record = shared.record(&record_id).unwrap();
21782        assert_eq!(record.state, ManagedSessionState::Live);
21783        assert!(record.provider_session.is_none());
21784        assert!(record.environment_profile.is_none());
21785        assert!(record.bundle.is_none());
21786        assert!(record.context_id.is_none());
21787        assert!(record.context.is_none());
21788        assert_eq!(record.active_session.as_ref(), Some(&address));
21789        assert_eq!(
21790            shared
21791                .materializations
21792                .lock()
21793                .unwrap_or_else(|poisoned| poisoned.into_inner())
21794                .get(&materialization_id)
21795                .unwrap()
21796                .owner(),
21797            &MaterializationOwner::Session {
21798                incarnation_id: shared.incarnation_id,
21799                instance_id: address.session.instance_id,
21800                generation: address.session.generation,
21801            },
21802        );
21803        {
21804            let mut bindings = shared
21805                .session_bindings
21806                .lock()
21807                .unwrap_or_else(|poisoned| poisoned.into_inner());
21808            bindings
21809                .get_mut(&address.session.instance_id)
21810                .unwrap()
21811                .managed_worktree_lease_id = Some(lease.lease_id.clone());
21812        }
21813        let lease_snapshot = shared
21814            .managed_worktrees
21815            .lock()
21816            .unwrap_or_else(|poisoned| poisoned.into_inner())
21817            .bind_session(
21818                &lease.lease_id,
21819                ManagedWorktreeSessionHolder {
21820                    incarnation_id: shared.incarnation_id,
21821                    instance_id: address.session.instance_id,
21822                    generation: address.session.generation,
21823                },
21824                None,
21825                unix_time_ms(),
21826            )
21827            .unwrap();
21828        assert_eq!(lease_snapshot.active_session_count, 1);
21829        assert_eq!(lease_snapshot.managed_record_count, 0);
21830        shared.persist_state().unwrap();
21831        assert!(shared
21832            .managed_worktrees
21833            .lock()
21834            .unwrap_or_else(|poisoned| poisoned.into_inner())
21835            .get(&lease.lease_id)
21836            .unwrap()
21837            .record_holders
21838            .is_empty());
21839        shared
21840            .handle
21841            .dispatch(shared.prepare_command(ControlCommand::Register {
21842                instance_id: address.session.instance_id,
21843                agent_id: agent("claude"),
21844                transport: TransportKind::Pty,
21845            }))
21846            .unwrap();
21847        runtime.tick().await;
21848        shared.reconcile_managed_record(
21849            &record_id,
21850            &address,
21851            &ControlEvent {
21852                sequence: 1,
21853                command_id: None,
21854                instance_id: address.session.instance_id,
21855                generation: address.session.generation,
21856                event: ControlEventKind::Removed,
21857            },
21858            false,
21859            false,
21860        );
21861        let remove = shared.remove_session(&address);
21862        let drive_runtime = async {
21863            runtime.tick().await;
21864        };
21865        let (removed, ()) = tokio::join!(remove, drive_runtime);
21866        removed.unwrap();
21867
21868        assert!(!materialization_root.exists());
21869        assert!(!Path::new(&created.path).exists());
21870        assert_eq!(
21871            shared
21872                .managed_worktrees
21873                .lock()
21874                .unwrap_or_else(|poisoned| poisoned.into_inner())
21875                .get(&lease.lease_id)
21876                .unwrap()
21877                .state,
21878            ManagedWorktreeLeaseState::Removed,
21879        );
21880        let renamed = shared
21881            .rename_session_record(&record_id, "raw managed inventory".to_owned())
21882            .unwrap();
21883        assert_eq!(renamed.record_id, record_id);
21884        assert_eq!(renamed.display_name, "raw managed inventory");
21885        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21886        assert!(renamed.active_session.is_none());
21887
21888        drop(shared);
21889        drop(runtime);
21890        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21891        std::fs::remove_dir_all(git_root).unwrap();
21892    }
21893
21894    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21895        let root = std::env::current_dir().unwrap()
21896            .join("managed-test-target")
21897            .to_string_lossy()
21898            .into_owned();
21899        let lease = ManagedWorktreeLeaseRecord {
21900            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21901            source_workspace_id: WorkspaceId::new("primary").unwrap(),
21902            workspace_id: WorkspaceId::new("managed-test").unwrap(),
21903            profile_id: WorktreeProfileId::new("default").unwrap(),
21904            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21905            target_root: root.clone(),
21906            branch: "gate4agent/mw-test".to_owned(),
21907            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21908            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21909            retention: ManagedWorktreeRetention::RemoveWhenReleased,
21910            state: ManagedWorktreeLeaseState::Ready,
21911            session_holders: Vec::new(),
21912            record_holders: Vec::new(),
21913            cleanup_failure: None,
21914            created_at_unix_ms: 1,
21915            updated_at_unix_ms: 1,
21916        };
21917        shared.workspaces.write()
21918            .unwrap_or_else(|poisoned| poisoned.into_inner())
21919            .insert(lease.workspace_id.clone(), root);
21920        *shared.managed_worktrees.lock()
21921            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21922            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21923        lease
21924    }
21925
21926    #[cfg(feature = "fixture")]
21927    #[test]
21928    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21929        let mut shared = terminal_test_shared();
21930        shared.fixture_semantic_hook_policy = true;
21931        let lease = install_managed_lease(&shared);
21932        let address = SessionAddress {
21933            workspace_id: lease.workspace_id.clone(),
21934            session: SessionKey {
21935                instance_id: AgentInstanceId(206),
21936                generation: SessionGeneration(1),
21937            },
21938        };
21939        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21940        let effective_runtime_policy =
21941            shared.effective_spawn_runtime_policy(pre_admission_policy);
21942        assert_ne!(effective_runtime_policy, pre_admission_policy);
21943        assert!(effective_runtime_policy.provider_session_identity);
21944
21945        let record_id = shared
21946            .bind_spawn_session(
21947                &address,
21948                agent("claude"),
21949                SessionMode::Pty,
21950                effective_runtime_policy,
21951                SpawnRecordPolicy::Always,
21952                None,
21953            )
21954            .unwrap()
21955            .unwrap();
21956        assert_eq!(
21957            shared.record(&record_id).unwrap().state,
21958            ManagedSessionState::IdentityPending,
21959        );
21960        let (record_holder, raw_inventory_record_id) = {
21961            let mut bindings = shared
21962                .session_bindings
21963                .lock()
21964                .unwrap_or_else(|poisoned| poisoned.into_inner());
21965            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
21966            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
21967            managed_spawn_record_ownership(binding, effective_runtime_policy)
21968        };
21969        assert_eq!(record_holder.as_ref(), Some(&record_id));
21970        assert!(raw_inventory_record_id.is_none());
21971        let snapshot = shared
21972            .managed_worktrees
21973            .lock()
21974            .unwrap_or_else(|poisoned| poisoned.into_inner())
21975            .bind_session(
21976                &lease.lease_id,
21977                ManagedWorktreeSessionHolder {
21978                    incarnation_id: shared.incarnation_id,
21979                    instance_id: address.session.instance_id,
21980                    generation: address.session.generation,
21981                },
21982                record_holder,
21983                unix_time_ms(),
21984            )
21985            .unwrap();
21986        assert_eq!(snapshot.managed_record_count, 1);
21987        assert_eq!(
21988            shared
21989                .managed_worktrees
21990                .lock()
21991                .unwrap_or_else(|poisoned| poisoned.into_inner())
21992                .get(&lease.lease_id)
21993                .unwrap()
21994                .record_holders,
21995            vec![record_id.clone()],
21996        );
21997
21998        let identity = ProviderSessionIdentity {
21999            key: ProviderSessionKey::SessionId,
22000            id: "monitoring-fixture-managed-session".to_owned(),
22001            transcript_path: None,
22002        };
22003        shared.publish_control(ControlEvent {
22004            sequence: 1,
22005            command_id: None,
22006            instance_id: address.session.instance_id,
22007            generation: address.session.generation,
22008            event: ControlEventKind::ProviderEvent {
22009                sequence: 1,
22010                source: gate4agent_types::ProviderSource {
22011                    family: AdapterFamily::Hook,
22012                    binding: AdapterBinding::new(
22013                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
22014                        "fixture/v1",
22015                        gate4agent_types::AdapterVerification::SyntheticFixture,
22016                    )
22017                    .unwrap(),
22018                },
22019                source_sequence: 1,
22020                event: ProviderEvent::SessionIdentityObserved {
22021                    identity: identity.clone(),
22022                },
22023            },
22024        });
22025
22026        let live = shared.record(&record_id).unwrap();
22027        assert_eq!(live.state, ManagedSessionState::Live);
22028        assert_eq!(live.provider_session.as_ref(), Some(&identity));
22029        assert_eq!(live.active_session.as_ref(), Some(&address));
22030        assert_eq!(
22031            shared
22032                .managed_worktrees
22033                .lock()
22034                .unwrap_or_else(|poisoned| poisoned.into_inner())
22035                .get(&lease.lease_id)
22036                .unwrap()
22037                .record_holders,
22038            vec![record_id],
22039        );
22040    }
22041
22042    fn managed_v2_test_shared(
22043        allocation_root: &Path,
22044        state_path: Option<PathBuf>,
22045        profile_revision: &str,
22046    ) -> NodeShared {
22047        let profile = ManagedWorktreeProfile::new(
22048            WorktreeProfileId::new("default").unwrap(),
22049            WorktreeProfileRevision::new(profile_revision).unwrap(),
22050            allocation_root,
22051            "gate4agent",
22052            "HEAD",
22053            ManagedWorktreeRetention::RemoveWhenReleased,
22054        )
22055        .unwrap();
22056        let workspace = WorkspaceConfig::new(
22057            WorkspaceId::new("primary").unwrap(),
22058            std::env::current_dir().unwrap(),
22059        )
22060        .unwrap()
22061        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22062        .with_managed_worktree_profile(profile)
22063        .unwrap();
22064        let catalog = active_registry().unwrap();
22065        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22066        let mut shared = NodeShared::new(
22067            handle,
22068            "fixture-token".to_owned(),
22069            NodeId::new("node-terminal-test").unwrap(),
22070            vec![workspace],
22071            vec![agent("claude")],
22072        );
22073        shared.state_path = state_path;
22074        shared
22075    }
22076
22077    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22078        let mut spawn_spec = spawn_spec_fixture();
22079        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22080        ManagedWorktreeSpawnRequestV2 {
22081            spawn_spec,
22082            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22083            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22084        }
22085    }
22086
22087    #[test]
22088    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22089        let shared = terminal_test_shared();
22090        let mut lease = install_managed_lease(&shared);
22091        let session = SessionAddress {
22092            workspace_id: lease.workspace_id.clone(),
22093            session: SessionKey {
22094                instance_id: AgentInstanceId(73),
22095                generation: SessionGeneration(4),
22096            },
22097        };
22098        lease.state = ManagedWorktreeLeaseState::InUse;
22099        lease.session_holders.push(ManagedWorktreeSessionHolder {
22100            incarnation_id: shared.incarnation_id,
22101            instance_id: session.session.instance_id,
22102            generation: session.session.generation,
22103        });
22104        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22105        assert!(resolved.target.worktree_id.is_none());
22106
22107        let receipt = managed_spawn_receipt(
22108            &resolved,
22109            shared.incarnation_id,
22110            session.clone(),
22111            lease.snapshot(),
22112            None,
22113            None,
22114            None,
22115        );
22116        assert!(resolved.target.worktree_id.is_none());
22117        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22118        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22119        assert_eq!(receipt.spawn.session, session);
22120        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22121        let encoded = serde_json::to_vec(&receipt).unwrap();
22122        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22123        assert_eq!(decoded, receipt);
22124    }
22125
22126    #[tokio::test]
22127    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22128        let root = temporary_workspace_root("managed-v2-revision");
22129        let allocation = root.join("allocation");
22130        std::fs::create_dir_all(&allocation).unwrap();
22131        let shared = managed_v2_test_shared(
22132            &allocation,
22133            Some(root.join("node-state.json")),
22134            "v1",
22135        );
22136        let mut request = managed_v2_request("managed-v2-revision");
22137        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22138
22139        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22140
22141        assert_eq!(
22142            failure.code,
22143            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22144        );
22145        assert!(shared
22146            .managed_worktrees
22147            .lock()
22148            .unwrap_or_else(|poisoned| poisoned.into_inner())
22149            .records()
22150            .is_empty());
22151        assert!(shared
22152            .managed_spawn_replays
22153            .lock()
22154            .unwrap_or_else(|poisoned| poisoned.into_inner())
22155            .is_empty());
22156        std::fs::remove_dir_all(root).unwrap();
22157    }
22158
22159    #[test]
22160    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22161        let root = temporary_workspace_root("managed-v2-concurrent");
22162        let allocation = root.join("allocation");
22163        std::fs::create_dir_all(&allocation).unwrap();
22164        let shared = Arc::new(managed_v2_test_shared(
22165            &allocation,
22166            Some(root.join("node-state.json")),
22167            "v1",
22168        ));
22169        let request = managed_v2_request("managed-v2-concurrent");
22170        let barrier = Arc::new(std::sync::Barrier::new(3));
22171        let mut joins = Vec::new();
22172        for _ in 0..2 {
22173            let shared = Arc::clone(&shared);
22174            let request = request.clone();
22175            let barrier = Arc::clone(&barrier);
22176            joins.push(std::thread::spawn(move || {
22177                let digest = managed_spawn_request_digest_v2(&request).unwrap();
22178                barrier.wait();
22179                shared.acquire_durable_managed_spawn_v2(&request, digest)
22180            }));
22181        }
22182        barrier.wait();
22183        let results = joins
22184            .into_iter()
22185            .map(|join| join.join().unwrap())
22186            .collect::<Vec<_>>();
22187        assert_eq!(
22188            results
22189                .iter()
22190                .filter(|result| {
22191                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22192                })
22193                .count(),
22194            1,
22195        );
22196        assert_eq!(
22197            results
22198                .iter()
22199                .filter(|result| {
22200                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22201                })
22202                .count(),
22203            1,
22204        );
22205        assert_eq!(
22206            shared
22207                .managed_spawn_replays
22208                .lock()
22209                .unwrap_or_else(|poisoned| poisoned.into_inner())
22210                .len(),
22211            1,
22212        );
22213        assert!(shared
22214            .managed_worktrees
22215            .lock()
22216            .unwrap_or_else(|poisoned| poisoned.into_inner())
22217            .records()
22218            .is_empty());
22219        drop(shared);
22220        std::fs::remove_dir_all(root).unwrap();
22221    }
22222
22223    #[tokio::test]
22224    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22225        let root = temporary_workspace_root("managed-v2-linked-pending");
22226        let allocation = root.join("allocation");
22227        std::fs::create_dir_all(&allocation).unwrap();
22228        let state_path = root.join("node-state.json");
22229        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22230        let lease = install_managed_lease(&shared);
22231        let request = managed_v2_request("managed-v2-linked-pending");
22232        assert!(matches!(
22233            shared
22234                .acquire_durable_managed_spawn_v2(
22235                    &request,
22236                    managed_spawn_request_digest_v2(&request).unwrap(),
22237                )
22238                .unwrap(),
22239            DurableManagedSpawnReplayDecision::Reserved,
22240        ));
22241        {
22242            let _transaction = shared
22243                .state_transaction
22244                .lock()
22245                .unwrap_or_else(|poisoned| poisoned.into_inner());
22246            shared
22247                .link_durable_managed_spawn_v2_lease_locked(
22248                    &request.spawn_spec.idempotency_key,
22249                    &lease.lease_id,
22250                )
22251                .unwrap();
22252            shared.persist_state_locked().unwrap();
22253        }
22254        drop(shared);
22255
22256        let mut loaded = session_registry::load(
22257            Some(&state_path),
22258            &NodeId::new("node-terminal-test").unwrap(),
22259        )
22260        .unwrap();
22261        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22262        *reopened
22263            .managed_worktrees
22264            .lock()
22265            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22266            ManagedWorktreeRegistry::from_records(
22267                std::mem::take(&mut loaded.managed_worktrees),
22268                std::mem::take(&mut loaded.managed_worktree_tombstones),
22269            )
22270            .unwrap();
22271        *reopened
22272            .managed_spawn_replays
22273            .lock()
22274            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22275            .managed_spawn_replays
22276            .into_iter()
22277            .map(|record| (record.idempotency_key.clone(), record))
22278            .collect();
22279        let lease_count = reopened
22280            .managed_worktrees
22281            .lock()
22282            .unwrap_or_else(|poisoned| poisoned.into_inner())
22283            .records()
22284            .len();
22285
22286        let failure = reopened
22287            .spawn_managed_worktree_v2(request)
22288            .await
22289            .unwrap_err();
22290
22291        assert_eq!(
22292            failure.code,
22293            NodeFailureCode::ManagedWorktreeRecoveryRequired,
22294        );
22295        assert_eq!(
22296            reopened
22297                .managed_worktrees
22298                .lock()
22299                .unwrap_or_else(|poisoned| poisoned.into_inner())
22300                .records()
22301                .len(),
22302            lease_count,
22303        );
22304        std::fs::remove_dir_all(root).unwrap();
22305    }
22306
22307    #[tokio::test]
22308    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22309        let root = temporary_workspace_root("managed-v2-reopen");
22310        let allocation = root.join("allocation");
22311        std::fs::create_dir_all(&allocation).unwrap();
22312        let state_path = root.join("node-state.json");
22313        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22314        let lease = install_managed_lease(&shared);
22315        let request = managed_v2_request("managed-v2-reopen");
22316        let session = SessionAddress {
22317            workspace_id: lease.workspace_id.clone(),
22318            session: SessionKey {
22319                instance_id: AgentInstanceId(91),
22320                generation: SessionGeneration(1),
22321            },
22322        };
22323        let snapshot = {
22324            let mut registry = shared
22325                .managed_worktrees
22326                .lock()
22327                .unwrap_or_else(|poisoned| poisoned.into_inner());
22328            let current = registry.get_mut(&lease.lease_id).unwrap();
22329            current.state = ManagedWorktreeLeaseState::InUse;
22330            current.session_holders.push(ManagedWorktreeSessionHolder {
22331                incarnation_id: shared.incarnation_id,
22332                instance_id: session.session.instance_id,
22333                generation: session.session.generation,
22334            });
22335            current.snapshot()
22336        };
22337        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22338        let receipt = managed_spawn_receipt(
22339            &resolved,
22340            shared.incarnation_id,
22341            session,
22342            snapshot,
22343            None,
22344            None,
22345            None,
22346        );
22347        assert!(matches!(
22348            shared
22349                .acquire_durable_managed_spawn_v2(
22350                    &request,
22351                    managed_spawn_request_digest_v2(&request).unwrap(),
22352                )
22353                .unwrap(),
22354            DurableManagedSpawnReplayDecision::Reserved,
22355        ));
22356        {
22357            let _transaction = shared
22358                .state_transaction
22359                .lock()
22360                .unwrap_or_else(|poisoned| poisoned.into_inner());
22361            shared
22362                .link_durable_managed_spawn_v2_lease_locked(
22363                    &request.spawn_spec.idempotency_key,
22364                    &lease.lease_id,
22365                )
22366                .unwrap();
22367            shared.persist_state_locked().unwrap();
22368        }
22369        shared
22370            .commit_durable_managed_spawn_v2(&request, receipt.clone())
22371            .unwrap();
22372        let legacy_same_key = ManagedWorktreeSpawnRequest {
22373            spawn_spec: request.spawn_spec.clone(),
22374            worktree_profile_id: request.worktree_profile_id.clone(),
22375        };
22376        shared.remember_managed_spawn_attempt(
22377            Some(&request.spawn_spec.idempotency_key),
22378            legacy_same_key.clone(),
22379            Ok(receipt.clone()),
22380            Instant::now(),
22381        );
22382        assert!(shared
22383            .replay_managed_spawn(&legacy_same_key, Instant::now())
22384            .unwrap()
22385            .is_none());
22386        drop(shared);
22387
22388        let mut loaded = session_registry::load(
22389            Some(&state_path),
22390            &NodeId::new("node-terminal-test").unwrap(),
22391        )
22392        .unwrap();
22393        assert_eq!(loaded.managed_spawn_replays.len(), 1);
22394        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22395        *reopened
22396            .managed_worktrees
22397            .lock()
22398            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22399            ManagedWorktreeRegistry::from_records(
22400                std::mem::take(&mut loaded.managed_worktrees),
22401                std::mem::take(&mut loaded.managed_worktree_tombstones),
22402            )
22403            .unwrap();
22404        *reopened
22405            .managed_spawn_replays
22406            .lock()
22407            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22408            .managed_spawn_replays
22409            .into_iter()
22410            .map(|record| (record.idempotency_key.clone(), record))
22411            .collect();
22412        let lease_count_before = reopened
22413            .managed_worktrees
22414            .lock()
22415            .unwrap_or_else(|poisoned| poisoned.into_inner())
22416            .records()
22417            .len();
22418
22419        assert_eq!(
22420            reopened
22421                .spawn_managed_worktree_v2(request.clone())
22422                .await
22423                .unwrap(),
22424            receipt,
22425        );
22426        assert_eq!(
22427            reopened
22428                .managed_worktrees
22429                .lock()
22430                .unwrap_or_else(|poisoned| poisoned.into_inner())
22431                .records()
22432                .len(),
22433            lease_count_before,
22434        );
22435        let mut changed = request;
22436        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22437        let failure = reopened
22438            .spawn_managed_worktree_v2(changed)
22439            .await
22440            .unwrap_err();
22441        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22442        assert_eq!(
22443            reopened
22444                .managed_worktrees
22445                .lock()
22446                .unwrap_or_else(|poisoned| poisoned.into_inner())
22447                .records()
22448                .len(),
22449            lease_count_before,
22450        );
22451        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22452        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22453        let failure = reopened
22454            .spawn_managed_worktree_v2(new_key)
22455            .await
22456            .unwrap_err();
22457        assert_eq!(
22458            failure.code,
22459            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22460        );
22461        assert_eq!(
22462            reopened
22463                .managed_worktrees
22464                .lock()
22465                .unwrap_or_else(|poisoned| poisoned.into_inner())
22466                .records()
22467                .len(),
22468            lease_count_before,
22469        );
22470        assert_eq!(
22471            reopened
22472                .managed_spawn_replays
22473                .lock()
22474                .unwrap_or_else(|poisoned| poisoned.into_inner())
22475                .len(),
22476            1,
22477        );
22478        std::fs::remove_dir_all(root).unwrap();
22479    }
22480
22481    #[test]
22482    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22483        let shared = terminal_test_shared();
22484        let request = ManagedWorktreeSpawnRequest {
22485            spawn_spec: spawn_spec_fixture(),
22486            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22487        };
22488        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22489        shared.remember_managed_spawn(
22490            request.clone(),
22491            Err(expected.clone()),
22492            Instant::now(),
22493        );
22494
22495        assert_eq!(
22496            shared
22497                .replay_managed_spawn(&request, Instant::now())
22498                .unwrap()
22499                .unwrap(),
22500            Err(expected),
22501        );
22502        assert!(shared
22503            .managed_spawn_replays
22504            .lock()
22505            .unwrap_or_else(|poisoned| poisoned.into_inner())
22506            .is_empty());
22507    }
22508
22509    #[tokio::test]
22510    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22511        let shared = terminal_test_shared();
22512        let lease = install_managed_lease(&shared);
22513        let raw = shared.spawn_session(
22514            lease.workspace_id.clone(),
22515            agent("claude"),
22516            SessionMode::Pty,
22517            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22518            None,
22519        ).await.unwrap_err();
22520        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22521        let mut spec = spawn_spec_fixture();
22522        spec.target.workspace_id = lease.workspace_id.clone();
22523        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22524        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22525        let mut selected = spawn_spec_fixture();
22526        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22527        selected.target.worktree_id = Some(lease.workspace_id);
22528        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22529        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22530        assert!(shared.handle.snapshot().sessions.is_empty());
22531    }
22532
22533    #[test]
22534    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22535        let shared = terminal_test_shared();
22536        let lease = install_managed_lease(&shared);
22537        let snapshot = snapshot_for_wire(
22538            &shared, true, true, true, false, true, true, false, false, true,
22539        );
22540        assert!(snapshot.managed_worktrees.is_empty());
22541        assert!(snapshot.workspaces.iter().all(|workspace| {
22542            workspace.managed_worktree_profiles.is_none()
22543        }));
22544        let mut reply = ResponseEnvelope {
22545            request_id: 1,
22546            result: Ok(NodeResponse::Resync {
22547                event_sequence: 1,
22548                oldest_available_sequence: 1,
22549                snapshot: shared.snapshot(),
22550                events: vec![NodeEventEnvelope {
22551                    sequence: 1,
22552                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22553                }],
22554            }),
22555        };
22556        project_response_without_managed_worktrees(&mut reply);
22557        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22558            panic!("projection changed the response kind");
22559        };
22560        assert!(snapshot.managed_worktrees.is_empty());
22561        assert!(events.is_empty());
22562        let mut git = git_snapshot_for_parser();
22563        git.branch = Some(lease.branch.clone());
22564        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22565            lease_id: lease.lease_id.clone(),
22566            source_workspace_id: lease.source_workspace_id.clone(),
22567            branch: lease.branch.clone(),
22568            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22569            active_session_count: 1,
22570            managed_record_count: 0,
22571        });
22572        let mut inspection_reply = ResponseEnvelope {
22573            request_id: 2,
22574            result: Ok(NodeResponse::WorkspaceInspected {
22575                inspection: WorkspaceInspection {
22576                    workspace_id: lease.workspace_id.clone(),
22577                    entries: Vec::new(),
22578                    tree_truncated: false,
22579                    git,
22580                    truncation: None,
22581                },
22582            }),
22583        };
22584        project_response_without_managed_worktrees(&mut inspection_reply);
22585        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22586            panic!("projection changed the inspection response kind");
22587        };
22588        assert!(inspection.git.managed_worktree.is_none());
22589        let failure = managed_git_worktree_failure(GitWorktreeError {
22590            kind: GitWorktreeErrorKind::Failed,
22591            message: r"git failed at C:\private\managed-target".to_owned(),
22592        });
22593        assert!(!failure.message.contains("private"));
22594        assert!(!failure.message.contains("managed-target"));
22595    }
22596
22597    #[tokio::test]
22598    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22599        let root = temporary_workspace_root("managed-restart");
22600        let source = root.join("source");
22601        let allocation = root.join("allocation");
22602        std::fs::create_dir_all(&source).unwrap();
22603        std::fs::create_dir_all(&allocation).unwrap();
22604        run_test_git(&source, &["init"]);
22605        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22606        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22607        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22608        run_test_git(&source, &["add", "seed.txt"]);
22609        run_test_git(&source, &["commit", "-m", "seed"]);
22610        let profile = ManagedWorktreeProfile::new(
22611            WorktreeProfileId::new("default").unwrap(),
22612            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22613            &allocation,
22614            "gate4agent",
22615            "HEAD",
22616            ManagedWorktreeRetention::Retain,
22617        ).unwrap();
22618        let workspace = WorkspaceConfig::new(
22619            WorkspaceId::new("primary").unwrap(),
22620            &source,
22621        ).unwrap()
22622            .with_worktree_service_mode(WorktreeServiceMode::Managed)
22623            .with_managed_worktree_profile(profile.clone()).unwrap();
22624        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22625        let source_text = workspace.canonical_root().to_owned();
22626        let target_text = target.to_string_lossy().into_owned();
22627        let created = create_git_worktree(
22628            &source_text,
22629            &target_text,
22630            "gate4agent/mw-restart",
22631            Some("HEAD"),
22632        ).await.unwrap();
22633        let lease = ManagedWorktreeLeaseRecord {
22634            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22635            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22636            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22637            profile_id: profile.profile_id().clone(),
22638            profile_revision: profile.revision().clone(),
22639            target_root: created.path.clone(),
22640            branch: "gate4agent/mw-restart".to_owned(),
22641            base_commit: created.head.clone(),
22642            expected_head: None,
22643            retention: ManagedWorktreeRetention::Retain,
22644            state: ManagedWorktreeLeaseState::Allocating,
22645            session_holders: Vec::new(),
22646            record_holders: Vec::new(),
22647            cleanup_failure: None,
22648            created_at_unix_ms: 1,
22649            updated_at_unix_ms: 1,
22650        };
22651        let catalog = active_registry().unwrap();
22652        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22653        let shared = NodeShared::new_with_incarnation(
22654            handle,
22655            "fixture-token".to_owned(),
22656            NodeId::new("node-managed-restart").unwrap(),
22657            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22658            vec![workspace],
22659            vec![agent("claude")],
22660            ProviderRuntimeStatuses::default(),
22661            None,
22662            Vec::new(),
22663            Vec::new(),
22664            SpawnProfileRegistry::default(),
22665            None,
22666            None,
22667            None,
22668            None,
22669            Vec::new(),
22670            vec![lease.clone()],
22671            Vec::new(),
22672            Vec::new(),
22673            None,
22674            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22675        );
22676        shared.reconcile_managed_worktrees().await;
22677        let retained = shared.managed_worktrees.lock()
22678            .unwrap_or_else(|poisoned| poisoned.into_inner())
22679            .get(&lease.lease_id).unwrap().clone();
22680        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22681        assert!(shared.workspaces.read()
22682            .unwrap_or_else(|poisoned| poisoned.into_inner())
22683            .contains_key(&lease.workspace_id));
22684        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22685        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22686        assert!(!Path::new(&created.path).exists());
22687        std::fs::remove_dir_all(root).unwrap();
22688    }
22689
22690    fn run_test_git(root: &Path, arguments: &[&str]) {
22691        let output = std::process::Command::new("git")
22692            .arg("-C").arg(root).args(arguments).output().unwrap();
22693        assert!(
22694            output.status.success(),
22695            "git {:?} failed: {}",
22696            arguments,
22697            String::from_utf8_lossy(&output.stderr),
22698        );
22699    }
22700
22701    #[tokio::test]
22702    async fn spawn_spec_validation_fails_before_session_mutation() {
22703        let shared = terminal_test_shared();
22704        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22705            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22706        }));
22707        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22708            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22709        }));
22710        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22711            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22712        }));
22713
22714        let mut target_mismatch = spawn_spec_fixture();
22715        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22716        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22717        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22718
22719        let mut unknown_profile = spawn_spec_fixture();
22720        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22721        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22722        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22723        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22724
22725        let mut revision_mismatch = spawn_spec_fixture();
22726        revision_mismatch.idempotency_key =
22727            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22728        revision_mismatch.expected_profile_revision =
22729            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22730        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22731        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22732
22733        let mut unsupported_materializer = spawn_spec_fixture();
22734        unsupported_materializer.idempotency_key =
22735            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22736        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22737            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22738        };
22739        let failure = shared
22740            .spawn_from_spec(unsupported_materializer)
22741            .await
22742            .unwrap_err();
22743        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22744
22745        let failure = shared
22746            .spawn_session_with_deadline(
22747                WorkspaceId::new("primary").unwrap(),
22748                agent("claude"),
22749                SessionMode::Pty,
22750                gate4agent_types::TerminalSize {
22751                    rows: 24,
22752                    columns: 80,
22753                },
22754                None,
22755                None,
22756                None,
22757                None,
22758                None,
22759                None,
22760                SpawnRecordPolicy::ProviderIdentityOnly,
22761                Some(Instant::now()),
22762                &[],
22763                None,
22764                ApprovalLevel::default(),
22765                None,
22766                None,
22767            )
22768            .await
22769            .unwrap_err();
22770        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22771        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22772        assert!(shared.handle.snapshot().sessions.is_empty());
22773    }
22774
22775    #[test]
22776    fn worktree_selection_requires_its_separate_negotiated_capability() {
22777        let mut spec = spawn_spec_fixture();
22778        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22779        let request = NodeRequest::SpawnSpec { spec };
22780        let spawn_spec_capability =
22781            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22782        assert!(request_uses_unnegotiated_capability(
22783            &request,
22784            &[spawn_spec_capability.clone()],
22785        ));
22786        let worktree_capability =
22787            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22788        assert!(request_uses_unnegotiated_capability(
22789            &request,
22790            &[spawn_spec_capability.clone(), worktree_capability.clone()],
22791        ));
22792        let profile_revision_capability =
22793            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22794        assert!(request_uses_unnegotiated_capability(
22795            &request,
22796            &[
22797                spawn_spec_capability.clone(),
22798                worktree_capability.clone(),
22799                profile_revision_capability.clone(),
22800            ],
22801        ));
22802        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
22803        // `Inherit`, not `Clear` — the profile's own defaults might still
22804        // materialize a bundle or a context pack, so both capabilities stay
22805        // conservatively required until negotiated (see
22806        // `requires_session_bundle_materialization_capability` /
22807        // `requires_history_context_pack_capability`).
22808        let bundle_materialization_capability =
22809            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22810        assert!(request_uses_unnegotiated_capability(
22811            &request,
22812            &[
22813                spawn_spec_capability.clone(),
22814                worktree_capability.clone(),
22815                profile_revision_capability.clone(),
22816                bundle_materialization_capability.clone(),
22817            ],
22818        ));
22819        let history_context_pack_capability =
22820            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22821        assert!(!request_uses_unnegotiated_capability(
22822            &request,
22823            &[
22824                spawn_spec_capability,
22825                worktree_capability,
22826                profile_revision_capability,
22827                bundle_materialization_capability,
22828                history_context_pack_capability,
22829            ],
22830        ));
22831    }
22832
22833    #[tokio::test]
22834    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22835        let mut shared = terminal_test_shared();
22836        let spec = spawn_spec_fixture();
22837        let receipt = shared
22838            .resolve_spawn_spec(&spec)
22839            .unwrap()
22840            .receipt(shared.incarnation_id, terminal_address(1));
22841        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22842
22843        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22844        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22845        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22846
22847        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22848
22849        let mut revision_conflicting = spec.clone();
22850        revision_conflicting.expected_profile_revision =
22851            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22852        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22853        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22854
22855        let mut stale_cache_miss = spec.clone();
22856        stale_cache_miss.idempotency_key =
22857            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22858        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22859        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22860
22861        let mut conflicting = spec;
22862        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22863        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22864        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22865        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22866        assert!(shared.handle.snapshot().sessions.is_empty());
22867    }
22868
22869    #[tokio::test]
22870    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22871        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22872        let mut spec = spawn_spec_fixture();
22873        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22874        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22875        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22876
22877        let failure = shared
22878            .create_worktree(
22879                WorkspaceId::new("primary").unwrap(),
22880                WorkspaceId::new("new-worktree").unwrap(),
22881                "not-an-absolute-path".to_owned(),
22882                "invalid branch".to_owned(),
22883                None,
22884            )
22885            .await
22886            .unwrap_err();
22887        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22888
22889        let failure = shared
22890            .remove_worktree(
22891                WorkspaceId::new("primary").unwrap(),
22892                "not-an-absolute-path".to_owned(),
22893            )
22894            .await
22895            .unwrap_err();
22896        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22897        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22898        assert!(shared.handle.snapshot().sessions.is_empty());
22899    }
22900
22901    #[tokio::test]
22902    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22903        let root = temporary_workspace_root("manual-worktree-compensation");
22904        let source = root.join("source");
22905        let target = root.join("target");
22906        let invalid_state_parent = root.join("state-parent-is-a-file");
22907        let invalid_state_path = invalid_state_parent.join("state.json");
22908        std::fs::create_dir_all(&source).unwrap();
22909        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22910        run_test_git(&source, &["init"]);
22911        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22912        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22913        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22914        run_test_git(&source, &["add", "seed.txt"]);
22915        run_test_git(&source, &["commit", "-m", "seed"]);
22916
22917        let catalog = active_registry().unwrap();
22918        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22919        let workspace = WorkspaceConfig::new(
22920            WorkspaceId::new("primary").unwrap(),
22921            &source,
22922        ).unwrap();
22923        let mut shared = NodeShared::new(
22924            handle,
22925            "fixture-token".to_owned(),
22926            NodeId::new("node-worktree-compensation").unwrap(),
22927            vec![workspace],
22928            vec![agent("claude")],
22929        );
22930        shared.state_path = Some(invalid_state_path);
22931
22932        let error = shared.create_worktree(
22933            WorkspaceId::new("primary").unwrap(),
22934            WorkspaceId::new("compensated").unwrap(),
22935            target.to_string_lossy().into_owned(),
22936            "gate4agent/compensated".to_owned(),
22937            Some("HEAD".to_owned()),
22938        ).await.unwrap_err();
22939
22940        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22941        assert!(!target.exists());
22942        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22943            .iter()
22944            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22945        std::fs::remove_dir_all(root).unwrap();
22946    }
22947
22948    #[test]
22949    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22950        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22951            NativeGitWorktreeSnapshot {
22952                path: path.to_owned(),
22953                head: "0123456789abcdef".to_owned(),
22954                branch: Some("refs/heads/test".to_owned()),
22955                is_bare,
22956                is_main,
22957                locked: false,
22958                lock_reason: None,
22959                prunable: false,
22960                prunable_reason: None,
22961                workspace_id: None,
22962            }
22963        }
22964
22965        let source = r"C:\repo";
22966        let selected = r"C:\repo-worktree";
22967        let listed = vec![
22968            worktree(source, true, false),
22969            worktree(selected, false, false),
22970        ];
22971        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
22972
22973        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
22974        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
22975
22976        let bare = vec![
22977            worktree(source, true, false),
22978            worktree(selected, false, true),
22979        ];
22980        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
22981        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
22982
22983        let foreign_failure =
22984            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
22985        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
22986    }
22987
22988    fn terminal_frame(sequence: u64) -> TerminalFrame {
22989        TerminalFrame {
22990            sequence,
22991            size: gate4agent_types::TerminalSize {
22992                rows: 24,
22993                columns: 80,
22994            },
22995            cursor_row: 2,
22996            cursor_column: 3,
22997            contents: format!("frame-{sequence}"),
22998            formatted: vec![1, 2, 3],
22999            scrollback_formatted: Vec::new(),
23000            alternate_screen: false,
23001            mouse_protocol_enabled: false,
23002            mouse_protocol_encoding: Default::default(),
23003            produced_at_unix_ms: 0,
23004            screen_state: gate4agent_types::PtyScreenState::default(),
23005            bracketed_paste: None,
23006        }
23007    }
23008
23009    #[test]
23010    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23011        let shared = terminal_test_shared();
23012        let mut receiver = shared.terminal_event_tx.subscribe();
23013        let original = terminal_address(1);
23014
23015        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23016        let first = receiver.try_recv().unwrap();
23017        assert_eq!(first.len(), 1);
23018        assert!(matches!(
23019            &first[0].event,
23020            NodeEvent::TerminalFrame { address, frame }
23021                if address == &original && frame.sequence == 7
23022        ));
23023
23024        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23025        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23026        assert!(matches!(
23027            receiver.try_recv(),
23028            Err(broadcast::error::TryRecvError::Empty)
23029        ));
23030
23031        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23032        let advanced = receiver.try_recv().unwrap();
23033        assert_eq!(advanced[0].sequence, 2);
23034
23035        let resumed = terminal_address(2);
23036        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23037        let reset = receiver.try_recv().unwrap();
23038        assert_eq!(reset[0].sequence, 3);
23039        assert!(matches!(
23040            &reset[0].event,
23041            NodeEvent::TerminalFrame { address, frame }
23042                if address == &resumed && frame.sequence == 1
23043        ));
23044        shared.clear_terminal_frame_watermark(&resumed);
23045        assert!(!shared
23046            .terminal_frame_watermarks
23047            .lock()
23048            .unwrap_or_else(|poisoned| poisoned.into_inner())
23049            .contains_key(&resumed.session.instance_id));
23050    }
23051
23052    #[test]
23053    fn terminal_frame_batches_replace_without_entering_node_event_history() {
23054        let shared = terminal_test_shared();
23055        let mut receiver = shared.terminal_event_tx.subscribe();
23056        let address = terminal_address(1);
23057        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23058
23059        for sequence in 1..=count {
23060            shared.publish_terminal_frame_candidates(vec![(
23061                address.clone(),
23062                terminal_frame(sequence),
23063            )]);
23064        }
23065
23066        let history = shared
23067            .history
23068            .lock()
23069            .unwrap_or_else(|poisoned| poisoned.into_inner());
23070        assert!(history.events.is_empty());
23071        assert_eq!(history.last_sequence, count);
23072        assert_eq!(history.replay_floor_sequence, 1);
23073        drop(history);
23074        let NodeResponse::Resync {
23075            event_sequence,
23076            oldest_available_sequence,
23077            events,
23078            ..
23079        } = shared.resync(0) else {
23080            unreachable!("resync helper returned another response")
23081        };
23082        assert_eq!(event_sequence, count);
23083        assert_eq!(oldest_available_sequence, 1);
23084        assert!(events.is_empty());
23085        assert!(matches!(
23086            resync_required_event(&shared).event,
23087            NodeEvent::ResyncRequired {
23088                oldest_available_sequence: 1,
23089            }
23090        ));
23091        assert!(matches!(
23092            receiver.try_recv(),
23093            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23094        ));
23095        let latest = receiver.try_recv().unwrap();
23096        assert_eq!(latest.len(), 1);
23097        assert_eq!(latest[0].sequence, count);
23098    }
23099
23100    #[test]
23101    fn real_durable_eviction_advances_authoritative_replay_floor() {
23102        let shared = terminal_test_shared();
23103        for _ in 0..=NODE_EVENT_HISTORY_MAX {
23104            shared.publish(NodeEvent::ControllerChanged { controller: None });
23105        }
23106
23107        let NodeResponse::Resync {
23108            event_sequence,
23109            oldest_available_sequence,
23110            events,
23111            ..
23112        } = shared.resync(0) else {
23113            unreachable!("resync helper returned another response")
23114        };
23115        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23116        assert_eq!(oldest_available_sequence, 2);
23117        assert_eq!(events.first().map(|event| event.sequence), Some(2));
23118        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23119        assert!(matches!(
23120            resync_required_event(&shared).event,
23121            NodeEvent::ResyncRequired {
23122                oldest_available_sequence: 2,
23123            }
23124        ));
23125    }
23126
23127    #[test]
23128    fn legacy_terminal_frame_connection_has_no_live_subscription() {
23129        let shared = terminal_test_shared();
23130        let without_capability = terminal_event_subscription(&shared, false);
23131        let with_capability = terminal_event_subscription(&shared, true);
23132        assert!(without_capability.is_none());
23133        assert!(with_capability.is_some());
23134        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23135            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23136        }));
23137    }
23138
23139    #[test]
23140    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23141        let mut pending = Vec::new();
23142        let mut event_sequence = 0_u64;
23143        for frame_sequence in 1..=4 {
23144            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23145                event_sequence += 1;
23146                let mut address = terminal_address(1);
23147                address.session.instance_id = AgentInstanceId(instance);
23148                queue_connection_event(
23149                    &mut pending,
23150                    NodeEventEnvelope {
23151                        sequence: event_sequence,
23152                        event: NodeEvent::TerminalFrame {
23153                            address,
23154                            frame: terminal_frame(frame_sequence),
23155                        },
23156                    },
23157                    0,
23158                );
23159            }
23160        }
23161        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23162        pending.sort_unstable_by_key(|event| event.sequence);
23163
23164        let mut uninterrupted = pending.clone();
23165        let first_burst = connection_event_burst_len(uninterrupted.len());
23166        uninterrupted.drain(..first_burst);
23167        let second_burst = connection_event_burst_len(uninterrupted.len());
23168        uninterrupted.drain(..second_burst);
23169        assert!(uninterrupted.is_empty());
23170
23171        let (frame_tx, mut frame_rx) =
23172            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23173        frame_tx
23174            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23175                request_id: 77,
23176                request: NodeRequest::Snapshot,
23177            })))
23178            .unwrap();
23179        let burst_len = connection_event_burst_len(pending.len());
23180        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23181        pending.drain(..burst_len);
23182        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23183        assert!(matches!(
23184            frame_rx.try_recv(),
23185            Ok(Ok(ClientFrame::Request(RequestEnvelope {
23186                request_id: 77,
23187                request: NodeRequest::Snapshot,
23188            })))
23189        ));
23190    }
23191
23192    #[test]
23193    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23194        let mut pending = Vec::new();
23195        for sequence in [7_u64, 8] {
23196            let mut address = terminal_address(1);
23197            address.session.instance_id = AgentInstanceId(sequence);
23198            queue_connection_event(
23199                &mut pending,
23200                NodeEventEnvelope {
23201                    sequence,
23202                    event: NodeEvent::TerminalFrame {
23203                        address,
23204                        frame: terminal_frame(sequence),
23205                    },
23206                },
23207                0,
23208            );
23209        }
23210        let mut discard_through = 0;
23211        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23212        assert!(pending.is_empty());
23213
23214        let replayed = NodeEventEnvelope {
23215            sequence: 8,
23216            event: NodeEvent::TerminalFrame {
23217                address: terminal_address(1),
23218                frame: terminal_frame(8),
23219            },
23220        };
23221        let advanced = NodeEventEnvelope {
23222            sequence: 9,
23223            event: NodeEvent::TerminalFrame {
23224                address: terminal_address(2),
23225                frame: terminal_frame(1),
23226            },
23227        };
23228        queue_connection_event_batch(
23229            &mut pending,
23230            &[replayed, advanced.clone()],
23231            discard_through,
23232        );
23233        assert_eq!(pending, vec![advanced]);
23234    }
23235
23236    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23237        ManagedSessionRecord {
23238            record_id: SessionRecordId::new(record_id).unwrap(),
23239            display_name: format!("{provider} record"),
23240            provider: agent(provider),
23241            mode: SessionMode::Pty,
23242            state: ManagedSessionState::Unavailable,
23243            workspace_id: WorkspaceId::new("primary").unwrap(),
23244            canonical_root: opaque_windows_path(
23245                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23246            ),
23247            provider_session: None,
23248            active_session: None,
23249            environment_profile: None,
23250            bundle: None,
23251            context_id: None,
23252            context: None,
23253            exported_context: None,
23254            task_binding: None,
23255            created_at_unix_ms: 1,
23256            updated_at_unix_ms: 1,
23257            last_error: None,
23258        }
23259    }
23260
23261    fn retention_test_record(
23262        workspace_id: &str,
23263        record_id: &str,
23264        state: ManagedSessionState,
23265        updated_at_unix_ms: u64,
23266    ) -> ManagedSessionRecord {
23267        ManagedSessionRecord {
23268            record_id: SessionRecordId::new(record_id).unwrap(),
23269            display_name: format!("{record_id} display"),
23270            provider: agent("claude"),
23271            mode: SessionMode::Pty,
23272            state,
23273            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23274            canonical_root: opaque_windows_path(
23275                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23276            ),
23277            provider_session: None,
23278            active_session: None,
23279            environment_profile: None,
23280            bundle: None,
23281            context_id: None,
23282            context: None,
23283            exported_context: None,
23284            task_binding: None,
23285            created_at_unix_ms: updated_at_unix_ms,
23286            updated_at_unix_ms,
23287            last_error: None,
23288        }
23289    }
23290
23291    /// Two workspaces of three `Unavailable` records each, staggered by
23292    /// age, plus one never-touchable decoy per non-`Unavailable` state in
23293    /// `workspace-a` (all as old as that workspace's oldest real record --
23294    /// if the sweep's state gate ever leaked, these would be the first
23295    /// thing it wrongly retired). Ages, oldest to newest:
23296    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
23297    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
23298    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23299        vec![
23300            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23301            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23302            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23303            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23304            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23305            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23306            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23307            retention_test_record(
23308                "workspace-a",
23309                "decoy-pending",
23310                ManagedSessionState::IdentityPending,
23311                now - 500_000,
23312            ),
23313            retention_test_record(
23314                "workspace-a",
23315                "decoy-dormant",
23316                ManagedSessionState::Dormant,
23317                now - 500_000,
23318            ),
23319        ]
23320    }
23321
23322    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23323        let root = temporary_workspace_root("session-record-retention");
23324        std::fs::create_dir_all(&root).unwrap();
23325        let catalog = active_registry().unwrap();
23326        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23327        let workspace_a =
23328            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23329        let workspace_b =
23330            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23331        let mut shared = NodeShared::new(
23332            handle,
23333            "fixture-token".to_owned(),
23334            NodeId::new("node-retention-test").unwrap(),
23335            vec![workspace_a, workspace_b],
23336            vec![agent("claude")],
23337        );
23338        shared.state_path = Some(root.join("node-state-v10.json"));
23339        let now = unix_time_ms();
23340        for record in retention_fixture_records(now) {
23341            shared.insert_record(record).unwrap();
23342        }
23343        shared
23344            .persist_state_locked()
23345            .expect("fixture-only, empty-history persist against a fresh temp state path");
23346        (shared, runtime, root, now)
23347    }
23348
23349    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23350        shared
23351            .history
23352            .lock()
23353            .unwrap_or_else(|poisoned| poisoned.into_inner())
23354            .events
23355            .iter()
23356            .filter_map(|envelope| match &envelope.event {
23357                NodeEvent::SessionRecordRemoved { record_id } => {
23358                    Some(record_id.as_str().to_owned())
23359                }
23360                _ => None,
23361            })
23362            .collect()
23363    }
23364
23365    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23366        shared
23367            .session_records
23368            .lock()
23369            .unwrap_or_else(|poisoned| poisoned.into_inner())
23370            .records
23371            .keys()
23372            .map(|record_id| record_id.as_str().to_owned())
23373            .collect()
23374    }
23375
23376    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23377        values.iter().map(|value| (*value).to_owned()).collect()
23378    }
23379
23380    #[tokio::test]
23381    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23382        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23383        shared.session_record_retention = SessionRecordRetentionConfig {
23384            age_ms: 0,
23385            keep_per_workspace: 1,
23386        };
23387        shared.retire_unavailable_session_records().await;
23388
23389        assert_eq!(
23390            retention_remaining_record_ids(&shared),
23391            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23392        );
23393        assert_eq!(
23394            retention_removed_record_ids(&shared),
23395            owned(&["a1", "a2", "b1", "b2"]),
23396        );
23397        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23398
23399        drop(shared);
23400        drop(runtime);
23401        std::fs::remove_dir_all(root).unwrap();
23402    }
23403
23404    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
23405    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
23406    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
23407    /// 200s old against a 250s threshold) even though it fails the keep-N
23408    /// test too, proving retirement needs BOTH, not either.
23409    #[tokio::test]
23410    async fn session_record_retention_both_set_retires_only_the_intersection() {
23411        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23412        shared.session_record_retention = SessionRecordRetentionConfig {
23413            age_ms: 250_000,
23414            keep_per_workspace: 1,
23415        };
23416        shared.retire_unavailable_session_records().await;
23417
23418        assert_eq!(
23419            retention_remaining_record_ids(&shared),
23420            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23421        );
23422        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23423        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23424
23425        drop(shared);
23426        drop(runtime);
23427        std::fs::remove_dir_all(root).unwrap();
23428    }
23429
23430    #[tokio::test]
23431    async fn session_record_retention_both_disabled_is_a_no_op() {
23432        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23433        shared.session_record_retention = SessionRecordRetentionConfig::default();
23434        shared.retire_unavailable_session_records().await;
23435
23436        assert_eq!(
23437            retention_remaining_record_ids(&shared),
23438            owned(&[
23439                "a1", "a2", "a3", "b1", "b2", "b3",
23440                "decoy-live", "decoy-pending", "decoy-dormant",
23441            ]),
23442        );
23443        assert!(retention_removed_record_ids(&shared).is_empty());
23444        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23445
23446        drop(shared);
23447        drop(runtime);
23448        std::fs::remove_dir_all(root).unwrap();
23449    }
23450
23451    #[test]
23452    fn session_record_context_export_selects_one_exact_history_identity() {
23453        let candidates = vec![
23454            HistoryCandidateSummary {
23455                id: "candidate-other".to_owned(),
23456                session_id_hint: "provider-other".to_owned(),
23457                modified_at_unix_ms: Some(1),
23458            },
23459            HistoryCandidateSummary {
23460                id: "candidate-exact".to_owned(),
23461                session_id_hint: "provider-exact".to_owned(),
23462                modified_at_unix_ms: Some(2),
23463            },
23464        ];
23465        assert_eq!(
23466            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23467            "candidate-exact",
23468        );
23469    }
23470
23471    #[test]
23472    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23473        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23474            id: id.to_owned(),
23475            session_id_hint: hint.to_owned(),
23476            modified_at_unix_ms: None,
23477        };
23478        let missing = select_history_candidate(
23479            &[candidate("candidate-other", "provider-other")],
23480            Some("provider-exact"),
23481        )
23482        .unwrap_err();
23483        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23484        let ambiguous = select_history_candidate(
23485            &[
23486                candidate("candidate-a", "provider-exact"),
23487                candidate("candidate-b", "provider-exact"),
23488            ],
23489            Some("provider-exact"),
23490        )
23491        .unwrap_err();
23492        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23493    }
23494
23495    /// The PTY-sidecar-observation shape (no fleet provider today, but
23496    /// capability-keyed rather than vendor-locked) never carries a verified
23497    /// `provider_session`; its record-bound export instead falls back to
23498    /// "exactly one discovered candidate" (see
23499    /// `export_context_pack_for_session_record_inner`). This is the same
23500    /// ambiguity refusal as the identity-filtered path, just without an
23501    /// identity to filter by.
23502    #[test]
23503    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23504        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23505            id: id.to_owned(),
23506            session_id_hint: hint.to_owned(),
23507            modified_at_unix_ms: None,
23508        };
23509        assert_eq!(
23510            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23511                .unwrap(),
23512            "candidate-sole",
23513        );
23514        let missing = select_history_candidate(&[], None).unwrap_err();
23515        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23516        let ambiguous = select_history_candidate(
23517            &[
23518                candidate("candidate-a", "sidecar-session-a"),
23519                candidate("candidate-b", "sidecar-session-b"),
23520            ],
23521            None,
23522        )
23523        .unwrap_err();
23524        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23525    }
23526
23527    #[test]
23528    fn session_record_context_export_revalidation_rejects_record_change() {
23529        let session = terminal_address(1);
23530        let identity = ProviderSessionIdentity {
23531            key: ProviderSessionKey::SessionId,
23532            id: "provider-exact".to_owned(),
23533            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23534        };
23535        let mut expected = record("claude", "record-context-export");
23536        expected.state = ManagedSessionState::Live;
23537        expected.provider_session = Some(identity.clone());
23538        expected.active_session = Some(session.clone());
23539        let binding = SessionBinding {
23540            workspace_id: session.workspace_id.clone(),
23541            generation: session.session.generation,
23542            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23543            pending_resume: None,
23544            record_id: Some(expected.record_id.clone()),
23545            managed_worktree_lease_id: None,
23546            environment_profile: None,
23547            bundle: None,
23548            context: None,
23549            materialization_id: None,
23550        };
23551        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23552        assert!(session_record_context_export_binding_is_exact(
23553            &expected,
23554            &expected,
23555            Some(&identity),
23556            &session,
23557            &binding,
23558            &expected.provider,
23559            &current_root,
23560            false,
23561        ));
23562        let mut changed = expected.clone();
23563        changed.updated_at_unix_ms += 1;
23564        assert!(!session_record_context_export_binding_is_exact(
23565            &expected,
23566            &changed,
23567            Some(&identity),
23568            &session,
23569            &binding,
23570            &expected.provider,
23571            &current_root,
23572            false,
23573        ));
23574        // A same-instant timestamp bump alone (state and active_session
23575        // otherwise unchanged) is not the clean-detachment transition
23576        // either, even with the reactive flag on: only the exact Live ->
23577        // Dormant + active_session cleared shape qualifies.
23578        assert!(!session_record_context_export_binding_is_exact(
23579            &expected,
23580            &changed,
23581            Some(&identity),
23582            &session,
23583            &binding,
23584            &expected.provider,
23585            &current_root,
23586            true,
23587        ));
23588        let mut detached = expected.clone();
23589        detached.state = ManagedSessionState::Dormant;
23590        detached.active_session = None;
23591        detached.updated_at_unix_ms += 1;
23592        assert!(!session_record_context_export_binding_is_exact(
23593            &expected,
23594            &detached,
23595            Some(&identity),
23596            &session,
23597            &binding,
23598            &expected.provider,
23599            &current_root,
23600            false,
23601        ));
23602        assert!(session_record_context_export_binding_is_exact(
23603            &expected,
23604            &detached,
23605            Some(&identity),
23606            &session,
23607            &binding,
23608            &expected.provider,
23609            &current_root,
23610            true,
23611        ));
23612    }
23613
23614    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
23615    /// for a record whose provider never carries a verified identity (the
23616    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
23617    /// declares one today): `identity` is `None` throughout, and the
23618    /// exactness check falls back to comparing `provider_session` itself,
23619    /// which must still be `None` on both sides.
23620    #[test]
23621    fn session_record_context_export_binding_is_exact_without_identity() {
23622        let session = terminal_address(1);
23623        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23624        expected.state = ManagedSessionState::Live;
23625        expected.active_session = Some(session.clone());
23626        let binding = SessionBinding {
23627            workspace_id: session.workspace_id.clone(),
23628            generation: session.session.generation,
23629            runtime_policy: ProviderRuntimePolicy::raw_pty(),
23630            pending_resume: None,
23631            record_id: Some(expected.record_id.clone()),
23632            managed_worktree_lease_id: None,
23633            environment_profile: None,
23634            bundle: None,
23635            context: None,
23636            materialization_id: None,
23637        };
23638        let current_root = windows_path_text(&expected.canonical_root).to_owned();
23639        assert!(session_record_context_export_binding_is_exact(
23640            &expected,
23641            &expected,
23642            None,
23643            &session,
23644            &binding,
23645            &expected.provider,
23646            &current_root,
23647            false,
23648        ));
23649        // A record that gained an identity between the initial lookup and
23650        // this recheck no longer matches the `None` this shape expects to
23651        // keep seeing -- treated the same as any other mid-export drift.
23652        let mut drifted = expected.clone();
23653        drifted.provider_session = Some(ProviderSessionIdentity {
23654            key: ProviderSessionKey::SessionId,
23655            id: "unexpected-identity".to_owned(),
23656            transcript_path: None,
23657        });
23658        assert!(!session_record_context_export_binding_is_exact(
23659            &expected,
23660            &drifted,
23661            None,
23662            &session,
23663            &binding,
23664            &expected.provider,
23665            &current_root,
23666            false,
23667        ));
23668    }
23669
23670    #[test]
23671    fn session_record_context_export_preflight_requires_live_usable_source() {
23672        let mut source = record("claude", "record-context-export-preflight");
23673        source.state = ManagedSessionState::Live;
23674        assert!(session_record_context_export_source_is_usable(
23675            &source,
23676            &SessionStatus::Running,
23677            false,
23678        ));
23679        assert!(session_record_context_export_source_is_usable(
23680            &source,
23681            &SessionStatus::Exited { exit_code: Some(0) },
23682            false,
23683        ));
23684
23685        source.state = ManagedSessionState::Dormant;
23686        assert!(!session_record_context_export_source_is_usable(
23687            &source,
23688            &SessionStatus::Running,
23689            false,
23690        ));
23691        // The reactive auto-export-at-exit path (allow_clean_detachment) is
23692        // the only caller that may treat a Dormant, cleanly-exited record as
23693        // still usable — the on-demand path above never does.
23694        assert!(session_record_context_export_source_is_usable(
23695            &source,
23696            &SessionStatus::Exited { exit_code: Some(0) },
23697            true,
23698        ));
23699        assert!(!session_record_context_export_source_is_usable(
23700            &source,
23701            &SessionStatus::Exited { exit_code: Some(0) },
23702            false,
23703        ));
23704        source.state = ManagedSessionState::Live;
23705        assert!(!session_record_context_export_source_is_usable(
23706            &source,
23707            &SessionStatus::Starting,
23708            false,
23709        ));
23710    }
23711
23712    #[test]
23713    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23714        let catalog = active_registry().unwrap();
23715        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23716        let workspace = WorkspaceConfig::new(
23717            WorkspaceId::new("primary").unwrap(),
23718            std::env::current_dir().unwrap(),
23719        )
23720        .unwrap();
23721        let shared = NodeShared::new(
23722            handle,
23723            "fixture-token".to_owned(),
23724            NodeId::new("node-context-export-conflict").unwrap(),
23725            vec![workspace],
23726            vec![agent("claude")],
23727        );
23728        let history = HistorySessionRecord {
23729            session_id: "provider-exact".to_owned(),
23730            title: None,
23731            cwd: None,
23732            model: None,
23733            message_count: 1,
23734            completed_turn_count: None,
23735            total_tokens: 0,
23736            messages: vec![gate4agent_types::HistoryMessageRecord {
23737                role: gate4agent_types::HistoryMessageRole::User,
23738                text: "bounded context".to_owned(),
23739            }],
23740        };
23741        let pack = NodeContextPack::export(
23742            ContextPackLineageReceipt {
23743                source_node_id: shared.node_id.clone(),
23744                source_session: terminal_address(1),
23745                source_provider: agent("claude"),
23746            },
23747            &history,
23748        )
23749        .unwrap();
23750        let context_id = pack.receipt().id.clone();
23751        let session = terminal_address(1);
23752        let identity = ProviderSessionIdentity {
23753            key: ProviderSessionKey::SessionId,
23754            id: history.session_id.clone(),
23755            transcript_path: None,
23756        };
23757        let mut expected = record("claude", "record-context-export-final-conflict");
23758        expected.state = ManagedSessionState::Live;
23759        expected.provider_session = Some(identity.clone());
23760        expected.active_session = Some(session.clone());
23761        let mut changed = expected.clone();
23762        changed.updated_at_unix_ms += 1;
23763        shared.insert_record(changed).unwrap();
23764
23765        let error = shared
23766            .commit_context_pack_for_session_record(
23767                &expected,
23768                Some(&identity),
23769                &session,
23770                "candidate-exact",
23771                &history,
23772                pack,
23773                false,
23774            )
23775            .unwrap_err();
23776
23777        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23778        assert!(shared
23779            .context_catalog
23780            .read()
23781            .unwrap_or_else(|poisoned| poisoned.into_inner())
23782            .get(&context_id)
23783            .is_none());
23784    }
23785
23786    #[tokio::test]
23787    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23788        let root = temporary_workspace_root("session-task-binding");
23789        std::fs::create_dir_all(&root).unwrap();
23790        let state_path = root.join("node-state.json");
23791        let catalog = active_registry().unwrap();
23792        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23793        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23794        let node_id = NodeId::new("node-1").unwrap();
23795        let mut shared = NodeShared::new(
23796            handle,
23797            "fixture-token".to_owned(),
23798            node_id.clone(),
23799            vec![workspace],
23800            vec![agent("claude")],
23801        );
23802        shared.state_path = Some(state_path.clone());
23803        let record = record("claude", "sr-task");
23804        let record_id = record.record_id.clone();
23805        shared.insert_record(record).unwrap();
23806        shared.persist_state().unwrap();
23807        shared
23808            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23809            .unwrap();
23810
23811        let minted = process_request_inner(
23812            &shared,
23813            77,
23814            ClientRole::Operator,
23815            NodeRequest::SetSessionTask {
23816                record_id: record_id.clone(),
23817                expected_revision: 0,
23818                target: SessionTaskTargetV1::New,
23819            },
23820        )
23821        .await
23822        .unwrap();
23823        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23824            panic!("session task mutation returned another response");
23825        };
23826        let minted_binding = minted.task_binding.clone().unwrap();
23827        let minted_task_id = minted_binding.task_id.clone().unwrap();
23828        assert_eq!(minted_binding.revision, 1);
23829        assert!(minted_task_id.as_str().starts_with("task-"));
23830        assert_eq!(minted_task_id.as_str().len(), 29);
23831
23832        let event_count = shared.current_sequence();
23833        let repeated = process_request_inner(
23834            &shared,
23835            77,
23836            ClientRole::Operator,
23837            NodeRequest::SetSessionTask {
23838                record_id: record_id.clone(),
23839                expected_revision: 1,
23840                target: SessionTaskTargetV1::Existing {
23841                    task_id: minted_task_id.clone(),
23842                },
23843            },
23844        )
23845        .await
23846        .unwrap();
23847        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23848        assert_eq!(shared.current_sequence(), event_count);
23849
23850        let stale = process_request_inner(
23851            &shared,
23852            77,
23853            ClientRole::Operator,
23854            NodeRequest::SetSessionTask {
23855                record_id: record_id.clone(),
23856                expected_revision: 0,
23857                target: SessionTaskTargetV1::Existing {
23858                    task_id: minted_task_id,
23859                },
23860            },
23861        )
23862        .await
23863        .unwrap_err();
23864        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23865
23866        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23867        let assigned = process_request_inner(
23868            &shared,
23869            77,
23870            ClientRole::Operator,
23871            NodeRequest::SetSessionTask {
23872                record_id: record_id.clone(),
23873                expected_revision: 1,
23874                target: SessionTaskTargetV1::Existing {
23875                    task_id: assigned_id.clone(),
23876                },
23877            },
23878        )
23879        .await
23880        .unwrap();
23881        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23882            panic!("session task assignment returned another response");
23883        };
23884        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23885        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23886
23887        let cleared = process_request_inner(
23888            &shared,
23889            77,
23890            ClientRole::Operator,
23891            NodeRequest::SetSessionTask {
23892                record_id: record_id.clone(),
23893                expected_revision: 2,
23894                target: SessionTaskTargetV1::Clear,
23895            },
23896        )
23897        .await
23898        .unwrap();
23899        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23900            panic!("session task clear returned another response");
23901        };
23902        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23903        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23904        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23905        assert_eq!(
23906            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23907            cleared.updated_at_unix_ms,
23908        );
23909
23910        let persisted = std::fs::read(&state_path).unwrap();
23911        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23912        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23913        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23914        std::fs::remove_dir_all(root).unwrap();
23915    }
23916
23917    #[test]
23918    fn session_task_capability_strips_snapshots_events_and_replies() {
23919        let mut bound = record("claude", "sr-task-projection");
23920        bound.updated_at_unix_ms = 2;
23921        bound.task_binding = Some(SessionTaskBindingV1 {
23922            revision: 1,
23923            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23924            changed_at_unix_ms: 2,
23925        });
23926        let request = NodeRequest::SetSessionTask {
23927            record_id: bound.record_id.clone(),
23928            expected_revision: 1,
23929            target: SessionTaskTargetV1::Clear,
23930        };
23931        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23932            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23933        }));
23934        assert!(!request_uses_unnegotiated_capability(
23935            &request,
23936            &baseline_capabilities().unwrap(),
23937        ));
23938        assert!(request_uses_unnegotiated_capability(&request, &[]));
23939
23940        let mut snapshot = terminal_test_shared().snapshot();
23941        snapshot.session_records.push(bound.clone());
23942        let mut reply = ResponseEnvelope {
23943            request_id: 1,
23944            result: Ok(NodeResponse::Resync {
23945                event_sequence: 1,
23946                oldest_available_sequence: 1,
23947                snapshot,
23948                events: vec![NodeEventEnvelope {
23949                    sequence: 1,
23950                    event: NodeEvent::SessionRecordUpserted {
23951                        record: bound.clone(),
23952                    },
23953                }],
23954            }),
23955        };
23956        project_response_without_session_task_binding(&mut reply);
23957        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23958            panic!("task projection changed the response kind");
23959        };
23960        assert!(snapshot.session_records[0].task_binding.is_none());
23961        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
23962            panic!("task projection changed the event kind");
23963        };
23964        assert!(record.task_binding.is_none());
23965
23966        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
23967            sequence: 2,
23968            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
23969        });
23970        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
23971            panic!("task event projection changed the event kind");
23972        };
23973        assert!(record.task_binding.is_none());
23974
23975        let mut direct = ResponseEnvelope {
23976            request_id: 2,
23977            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
23978        };
23979        project_response_without_session_task_binding(&mut direct);
23980        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
23981            panic!("task reply projection changed the response kind");
23982        };
23983        assert!(record.task_binding.is_none());
23984    }
23985
23986    #[test]
23987    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
23988        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
23989        let workspace = WorkspaceConfig::new(
23990            WorkspaceId::new("primary").unwrap(),
23991            std::env::current_dir().unwrap(),
23992        )
23993        .unwrap();
23994        let config = NodeServerConfig::new(
23995            DEFAULT_NODE_ENDPOINT,
23996            "fixture-token",
23997            NodeId::new("node-1").unwrap(),
23998            [workspace],
23999        )
24000        .unwrap();
24001        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24002    }
24003
24004    #[cfg(all(feature = "fixture", windows))]
24005    #[test]
24006    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24007        let root = temporary_workspace_root("provider-bundle-argv-hold");
24008        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24009        std::fs::create_dir_all(&root).unwrap();
24010        std::fs::create_dir_all(&outside).unwrap();
24011        let proof_path = root.join("provider.proof");
24012        let release_signal = root.join("release.signal");
24013        let config = NodeServerConfig::new(
24014            format!(
24015                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24016                std::process::id(),
24017            ),
24018            "fixture-token",
24019            NodeId::new("provider-bundle-hold-node").unwrap(),
24020            [WorkspaceConfig::new(
24021                WorkspaceId::new("primary").unwrap(),
24022                std::env::current_dir().unwrap(),
24023            )
24024            .unwrap()],
24025        )
24026        .unwrap()
24027        .with_spawn_profiles(SpawnProfileRegistry::new([
24028            crate::protocol::SpawnProfileDefaults {
24029                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24030                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24031                    .unwrap(),
24032                provider: agent("kimi"),
24033                mode: SessionMode::Pty,
24034                terminal_size: gate4agent_types::TerminalSize {
24035                    rows: 24,
24036                    columns: 80,
24037                },
24038                prompt: None,
24039                bundle_id: None,
24040                context_id: None,
24041                environment_profile_id: None,
24042            },
24043        ])
24044        .unwrap());
24045        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24046            config,
24047            agent("kimi"),
24048            proof_path.clone(),
24049            release_signal.clone(),
24050        )
24051        .unwrap();
24052        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24053
24054        let spec = NodeServer::provider_bundle_argv_fixture_spec(
24055            agent("kimi"),
24056            proof_path.clone(),
24057            Some(release_signal.clone()),
24058        )
24059        .unwrap();
24060        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24061        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24062        let release_wait = wrapper
24063            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24064            .unwrap();
24065        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24066        assert!(proof_write < release_wait);
24067        assert!(release_wait < fixture_ready);
24068        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24069        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24070        assert_eq!(
24071            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24072            &[
24073                proof_path.to_string_lossy().into_owned(),
24074                release_signal.to_string_lossy().into_owned(),
24075            ],
24076        );
24077
24078        let error = NodeServer::provider_bundle_argv_fixture_spec(
24079            agent("kimi"),
24080            proof_path,
24081            Some(outside.join("release.signal")),
24082        )
24083        .unwrap_err();
24084        assert!(matches!(
24085            error,
24086            NodeServerError::Registry(message)
24087                if message == "provider bundle argv release path must share the proof parent"
24088        ));
24089        drop(server);
24090        std::fs::remove_dir_all(root).unwrap();
24091        std::fs::remove_dir_all(outside).unwrap();
24092    }
24093
24094    #[cfg(feature = "fixture")]
24095    #[test]
24096    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24097        let root = temporary_workspace_root("context-fixture-registry");
24098        std::fs::create_dir_all(&root).unwrap();
24099        let proof_path = root.join("context.proof");
24100        let config = NodeServerConfig::new(
24101            format!(
24102                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24103                std::process::id(),
24104            ),
24105            "fixture-token",
24106            NodeId::new("context-fixture-node").unwrap(),
24107            [WorkspaceConfig::new(
24108                WorkspaceId::new("primary").unwrap(),
24109                std::env::current_dir().unwrap(),
24110            )
24111            .unwrap()],
24112        )
24113        .unwrap();
24114        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24115        let mut enabled = server
24116            .shared
24117            .enabled_providers
24118            .iter()
24119            .map(AgentId::as_str)
24120            .collect::<Vec<_>>();
24121        enabled.sort_unstable();
24122        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24123        for provider in &server.shared.enabled_providers {
24124            assert!(server
24125                .shared
24126                .provider_adapter_contracts
24127                .iter()
24128                .any(|contract| {
24129                    &contract.provider == provider
24130                        && contract.family == AdapterFamily::History
24131                }));
24132        }
24133        drop(server);
24134        std::fs::remove_dir_all(root).unwrap();
24135    }
24136
24137    #[cfg(feature = "fixture")]
24138    #[test]
24139    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24140        let root = temporary_workspace_root("monitoring-context-fixture");
24141        std::fs::create_dir_all(&root).unwrap();
24142        let proof_path = root.join("context.proof");
24143        let standard = NodeServer::context_pack_fixture_catalog(
24144            proof_path.clone(),
24145            None,
24146            false,
24147        ).unwrap();
24148        let monitoring = NodeServer::context_pack_fixture_catalog(
24149            proof_path.clone(),
24150            None,
24151            true,
24152        ).unwrap();
24153        let claude = AgentId::new("claude").unwrap();
24154        let standard_claude = standard.get(&claude).unwrap();
24155        let monitoring_claude = monitoring.get(&claude).unwrap();
24156        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
24157        // standard nor the monitoring fixture declares a hook adapter
24158        // anymore.
24159        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24160        assert_eq!(
24161            monitoring_claude.capabilities.adapters.history,
24162            standard_claude.capabilities.adapters.history,
24163        );
24164        assert_ne!(monitoring_claude.launch, standard_claude.launch);
24165        let codex = AgentId::new("codex").unwrap();
24166        let standard_codex = standard.get(&codex).unwrap();
24167        let monitoring_codex = monitoring.get(&codex).unwrap();
24168        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24169        assert_eq!(
24170            monitoring_codex.capabilities.adapters.history,
24171            standard_codex.capabilities.adapters.history,
24172        );
24173        assert_ne!(monitoring_codex.launch, standard_codex.launch);
24174        let codex_validation = monitoring_codex.launch.fixed_args.iter()
24175            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24176            .unwrap();
24177        for required in [
24178            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24179            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24180            "@('role', 'text')",
24181            "g4a-private-provider-session-canary",
24182            "private-provider-login-identity",
24183            "$rawProof",
24184            "$env:GATE4AGENT_HOOK_URL",
24185            "g4a-private-provider-session-canary",
24186        ] {
24187            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24188        }
24189        for provider in ["grok", "kimi"] {
24190            let provider = AgentId::new(provider).unwrap();
24191            assert_eq!(monitoring.get(&provider), standard.get(&provider));
24192        }
24193
24194        let config = NodeServerConfig::new(
24195            format!(
24196                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24197                std::process::id(),
24198            ),
24199            "fixture-token",
24200            NodeId::new("monitoring-context-fixture-node").unwrap(),
24201            [WorkspaceConfig::new(
24202                WorkspaceId::new("primary").unwrap(),
24203                std::env::current_dir().unwrap(),
24204            )
24205            .unwrap()],
24206        )
24207        .unwrap();
24208        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24209        assert!(server.shared.fixture_semantic_hook_policy);
24210        drop(server);
24211        std::fs::remove_dir_all(root).unwrap();
24212    }
24213
24214    #[cfg(feature = "fixture")]
24215    #[test]
24216    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24217        let root = temporary_workspace_root("kimi-context-only-fixture");
24218        std::fs::create_dir_all(&root).unwrap();
24219        let proof_path = root.join("context.proof");
24220        let standard = NodeServer::context_pack_fixture_catalog(
24221            proof_path.clone(),
24222            None,
24223            false,
24224        ).unwrap();
24225        let kimi = AgentId::new("kimi").unwrap();
24226        let context_only = NodeServer::context_pack_fixture_catalog(
24227            proof_path,
24228            Some(&kimi),
24229            false,
24230        )
24231        .unwrap();
24232
24233        let codex = AgentId::new("codex").unwrap();
24234        assert_eq!(
24235            standard.get(&codex).unwrap().launch.fixed_args,
24236            context_only.get(&codex).unwrap().launch.fixed_args,
24237        );
24238        let script = context_only
24239            .get(&kimi)
24240            .unwrap()
24241            .launch
24242            .fixed_args
24243            .iter()
24244            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24245            .expect("Kimi context-only validation script is missing");
24246        for required in [
24247            "$bundleArgs.Count -ne 0",
24248            "GATE4AGENT_CONTEXT_ROOT",
24249            "context-pack.json",
24250            "$entries.Count -ne 1",
24251            "g4a-context-pack-v1",
24252            "$document.PSObject.Properties['cwd']",
24253            "'claude', 'codex', 'grok', 'kimi'",
24254            "$hasUser",
24255            "$hasAssistant",
24256            "SHA256",
24257            "context-only",
24258        ] {
24259            assert!(script.contains(required), "missing validation: {required}");
24260        }
24261        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24262            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24263        }
24264        assert!(!standard
24265            .get(&kimi)
24266            .unwrap()
24267            .launch
24268            .fixed_args
24269            .iter()
24270            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24271
24272        std::fs::remove_dir_all(root).unwrap();
24273    }
24274
24275    #[cfg(all(feature = "fixture", windows))]
24276    #[tokio::test]
24277    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24278        let root = temporary_workspace_root("kimi-context-only-spawn");
24279        let workspace_root = root.join("workspace");
24280        std::fs::create_dir_all(&workspace_root).unwrap();
24281        let materialization_root = root.join("private-materializations");
24282        let proof_path = root.join("context.proof");
24283        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24284        let workspace_id = WorkspaceId::new("primary").unwrap();
24285        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24286        let endpoint = format!(
24287            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24288            std::process::id(),
24289            unix_time_ms(),
24290        );
24291        let spawn_profiles = SpawnProfileRegistry::new([
24292            crate::protocol::SpawnProfileDefaults {
24293                profile_id: profile_id.clone(),
24294                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24295                provider: agent("kimi"),
24296                mode: SessionMode::Pty,
24297                terminal_size: gate4agent_types::TerminalSize {
24298                    rows: 30,
24299                    columns: 120,
24300                },
24301                prompt: None,
24302                bundle_id: None,
24303                context_id: None,
24304                environment_profile_id: None,
24305            },
24306        ])
24307        .unwrap();
24308        let mut config = NodeServerConfig::new(
24309            &endpoint,
24310            "fixture-token",
24311            node_id.clone(),
24312            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24313        )
24314        .unwrap()
24315        .with_spawn_profiles(spawn_profiles)
24316        .with_session_environment_materialization(
24317            materialization_root,
24318            Arc::new(FixtureSecretResolver {
24319                deny: Arc::new(AtomicBool::new(false)),
24320            }),
24321        )
24322        .unwrap();
24323        config.fixture_raw_pty_runtime = true;
24324        let server = NodeServer::new_context_only_proof_fixture(
24325            config,
24326            agent("kimi"),
24327            proof_path.clone(),
24328        )
24329        .unwrap();
24330        let shared = Arc::clone(&server.shared);
24331        let shutdown = server.shutdown_handle();
24332
24333        let history = HistorySessionRecord {
24334            session_id: "context-only-source-history".to_owned(),
24335            title: None,
24336            cwd: None,
24337            model: None,
24338            message_count: 2,
24339            completed_turn_count: None,
24340            total_tokens: 0,
24341            messages: vec![
24342                gate4agent_types::HistoryMessageRecord {
24343                    role: gate4agent_types::HistoryMessageRole::User,
24344                    text: "continue the bounded parent run through native C2".to_owned(),
24345                },
24346                gate4agent_types::HistoryMessageRecord {
24347                    role: gate4agent_types::HistoryMessageRole::Assistant,
24348                    text: "the exact parent context is ready for the child run".to_owned(),
24349                },
24350            ],
24351        };
24352        let pack = NodeContextPack::export(
24353            ContextPackLineageReceipt {
24354                source_node_id: node_id.clone(),
24355                source_session: SessionAddress {
24356                    workspace_id: workspace_id.clone(),
24357                    session: SessionKey {
24358                        instance_id: AgentInstanceId(71),
24359                        generation: SessionGeneration(1),
24360                    },
24361                },
24362                source_provider: agent("codex"),
24363            },
24364            &history,
24365        )
24366        .unwrap();
24367        let expected_context_hash = digest(&SHA256, pack.bytes())
24368            .as_ref()
24369            .iter()
24370            .map(|byte| format!("{byte:02x}"))
24371            .collect::<String>();
24372        let context = shared
24373            .context_catalog
24374            .write()
24375            .unwrap_or_else(|poisoned| poisoned.into_inner())
24376            .insert(pack)
24377            .unwrap();
24378        let spec = SpawnSpec {
24379            target: crate::protocol::SpawnTarget {
24380                node_id,
24381                workspace_id: workspace_id.clone(),
24382                worktree_id: None,
24383            },
24384            profile_id,
24385            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24386                "target-kimi-r1",
24387            ).unwrap(),
24388            overrides: crate::protocol::SpawnOverrides {
24389                context_id: crate::protocol::SpawnOverride::Set {
24390                    value: context.id.clone(),
24391                },
24392                ..crate::protocol::SpawnOverrides::default()
24393            },
24394            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24395            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24396            required_capabilities: SpawnRequiredCapabilities::default(),
24397        };
24398        let resolved = match shared.resolve_spawn_spec(&spec) {
24399            Ok(resolved) => resolved,
24400            Err(error) => panic!(
24401                "Kimi context-only resolve rejected: exact_code={:?} message={}",
24402                error.code,
24403                error.message,
24404            ),
24405        };
24406        let resolved_context = match shared.resolve_context(&resolved) {
24407            Ok(Some(resolved_context)) => resolved_context,
24408            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24409            Err(error) => panic!(
24410                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24411                error.code,
24412                error.message,
24413            ),
24414        };
24415        assert_eq!(resolved_context, context);
24416        let runtime_policy = match shared
24417            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24418            .await
24419        {
24420            Ok(runtime_policy) => runtime_policy,
24421            Err(error) => panic!(
24422                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24423                error.code,
24424                error.message,
24425            ),
24426        };
24427        assert!(runtime_policy.raw_pty_lifecycle);
24428
24429        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24430        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24431            agent("kimi"),
24432            TransportKind::Pty,
24433            vec![EnvMutation {
24434                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24435                value: Some(OsString::from("must-remain-rejected")),
24436            }],
24437        )
24438        .unwrap();
24439        assert_eq!(
24440            launch_control
24441                .install_native_launch_environment_overlay(
24442                    AgentInstanceId(72),
24443                    generic_overlay,
24444                )
24445                .unwrap_err(),
24446            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24447        );
24448
24449        let diagnostic_address = SessionAddress {
24450            workspace_id: workspace_id.clone(),
24451            session: SessionKey {
24452                instance_id: AgentInstanceId(73),
24453                generation: SessionGeneration(1),
24454            },
24455        };
24456        let (diagnostic_overlay, diagnostic_materialization) = match shared
24457            .prepare_session_materialization(
24458                &diagnostic_address,
24459                &agent("kimi"),
24460                SessionMode::Pty,
24461                None,
24462                None,
24463                Some(&resolved_context),
24464                None,
24465            )
24466        {
24467            Ok(Some(prepared)) => prepared,
24468            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24469            Err(error) => panic!(
24470                "Kimi context-only materialization rejected: exact_code={:?} message={}",
24471                error.code,
24472                error.message,
24473            ),
24474        };
24475        let diagnostic_overlay = diagnostic_overlay
24476            .expect("Kimi context-only materialization omitted its environment overlay");
24477        assert!(matches!(
24478            &diagnostic_overlay,
24479            PreparedNativeLaunchOverlay::Environment(_),
24480        ));
24481        match shared.install_prepared_launch_overlay(
24482            diagnostic_address.session.instance_id,
24483            diagnostic_overlay,
24484        ) {
24485            Ok(None) => {}
24486            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24487            Err(error) => panic!(
24488                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24489                error.code,
24490                error.message,
24491            ),
24492        }
24493        assert!(launch_control.clear_native_instance_launch_overlay(
24494            diagnostic_address.session.instance_id,
24495        ));
24496        drop(diagnostic_materialization);
24497        assert!(shared
24498            .materializations
24499            .lock()
24500            .unwrap_or_else(|poisoned| poisoned.into_inner())
24501            .is_empty());
24502
24503        let node_task = tokio::spawn(server.run());
24504        let ready_client = timeout(Duration::from_secs(5), async {
24505            loop {
24506                match gate4agent_node_wire::LocalNodeClient::connect(
24507                    &endpoint,
24508                    &shared.node_id,
24509                    ClientRole::Observer,
24510                    "fixture-token",
24511                )
24512                .await
24513                {
24514                    Ok(client) => break client,
24515                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24516                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24517                }
24518            }
24519        })
24520        .await
24521        .expect("Kimi context-only Node did not become ready");
24522        assert_eq!(
24523            ready_client
24524                .hello()
24525                .snapshot
24526                .provider_runtime_statuses
24527                .iter()
24528                .find(|status| status.provider().as_str() == "kimi")
24529                .expect("Kimi runtime status is missing")
24530                .mode(),
24531            crate::protocol::ProviderRuntimeMode::RawPassthrough,
24532        );
24533        drop(ready_client);
24534
24535        let receipt = match shared.spawn_from_spec(spec).await {
24536            Ok(receipt) => receipt,
24537            Err(error) => panic!(
24538                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24539                error.code,
24540                error.message,
24541            ),
24542        };
24543        assert_eq!(receipt.provider, agent("kimi"));
24544        assert_eq!(receipt.context.as_ref(), Some(&context));
24545        assert!(receipt.context_binding_is_valid());
24546        timeout(Duration::from_secs(5), async {
24547            while !proof_path.is_file() {
24548                sleep(Duration::from_millis(10)).await;
24549            }
24550        })
24551        .await
24552        .expect("Kimi context-only fixture did not write its proof");
24553
24554        let proof = std::fs::read_to_string(&proof_path).unwrap();
24555        let lines = proof.lines().collect::<Vec<_>>();
24556        assert_eq!(lines.len(), 7);
24557        assert_eq!(lines[0], "context-only");
24558        assert!(Path::new(lines[1]).is_absolute());
24559        assert_ne!(
24560            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24561            std::fs::canonicalize(&workspace_root).unwrap(),
24562        );
24563        assert_eq!(
24564            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24565            std::fs::canonicalize(&workspace_root).unwrap(),
24566        );
24567        assert_eq!(lines[3], expected_context_hash);
24568        assert_eq!(lines[4], "g4a-context-pack-v1");
24569        assert_eq!(lines[5], "codex");
24570        assert_eq!(lines[6], "2");
24571        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24572            session.instance_id == receipt.session.session.instance_id
24573                && session.agent_id.as_str() == "kimi"
24574        }));
24575
24576        shutdown.request_shutdown().await.unwrap();
24577        timeout(Duration::from_secs(5), node_task)
24578            .await
24579            .unwrap()
24580            .unwrap()
24581            .unwrap();
24582        drop(shutdown);
24583        drop(shared);
24584        std::fs::remove_dir_all(root).unwrap();
24585    }
24586
24587    #[cfg(feature = "fixture")]
24588    #[test]
24589    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24590        let root = temporary_workspace_root("clean-exit-constructor");
24591        std::fs::create_dir_all(&root).unwrap();
24592        let config = construction_test_config("clean-exit-constructor")
24593            .with_state_path(root.join("node-state.json"))
24594            .unwrap();
24595        let server = NodeServer::new_clean_exit_fixture(
24596            config,
24597            root.clone(),
24598            root.join("started.marker"),
24599            root.join("release.signal"),
24600        )
24601        .unwrap();
24602        assert_eq!(
24603            server
24604                .shared
24605                .enabled_providers
24606                .iter()
24607                .map(AgentId::as_str)
24608                .collect::<Vec<_>>(),
24609            ["claude"],
24610        );
24611        drop(server);
24612        std::fs::remove_dir_all(root).unwrap();
24613    }
24614
24615    #[test]
24616    fn production_registry_provider_contract_manifest_is_exact() {
24617        let registry = active_registry().unwrap();
24618        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24619        assert_eq!(
24620            providers
24621                .iter()
24622                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24623                .collect::<Vec<_>>(),
24624            vec![
24625                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24626                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24627                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24628                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24629            ]
24630        );
24631        assert_eq!(
24632            adapters
24633                .iter()
24634                .map(|contract| (
24635                    contract.provider.as_str(),
24636                    contract.family,
24637                    contract.adapter_id.as_str(),
24638                    contract.revision.as_str(),
24639                ))
24640                .collect::<Vec<_>>(),
24641            vec![
24642                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24643                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24644                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24645                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24646                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24647                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24648                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24649                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24650                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24651                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24652                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24653                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24654                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24655                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24656                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24657                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24658                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24659                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24660                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24661                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24662                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24663                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24664                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24665            ]
24666        );
24667    }
24668
24669    fn construction_test_config(name: &str) -> NodeServerConfig {
24670        let workspace = WorkspaceConfig::new(
24671            WorkspaceId::new("primary").unwrap(),
24672            std::env::current_dir().unwrap(),
24673        )
24674        .unwrap();
24675        NodeServerConfig::new(
24676            format!(r"\\.\pipe\gate4agent-{name}"),
24677            "fixture-token",
24678            NodeId::new(name).unwrap(),
24679            [workspace],
24680        )
24681        .unwrap()
24682    }
24683
24684    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24685        let adapter = AdapterBinding::new(
24686            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24687            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24688            gate4agent_types::AdapterVerification::SyntheticFixture,
24689        )
24690        .unwrap();
24691        let agent_ids = ["claude", "codex", "kimi"]
24692            .into_iter()
24693            .map(|agent_id| AgentId::new(agent_id).unwrap())
24694            .collect::<Vec<_>>();
24695        let adapters = gate4agent_catalog::AdapterRegistry::new(
24696            [
24697                AdapterFamily::PtySemantic,
24698                AdapterFamily::Pipe,
24699                AdapterFamily::OneShot,
24700                AdapterFamily::Acp,
24701                AdapterFamily::Hook,
24702                AdapterFamily::ManagedHook,
24703                AdapterFamily::History,
24704                AdapterFamily::Resume,
24705                AdapterFamily::SessionOptions,
24706                AdapterFamily::CapabilityProbe,
24707            ]
24708            .into_iter()
24709            .map(|family| gate4agent_catalog::AdapterDescriptor {
24710                family,
24711                binding: adapter.clone(),
24712                agents: agent_ids.clone(),
24713            }),
24714        )
24715        .unwrap();
24716        let specs = agent_ids
24717            .iter()
24718            .map(|agent_id| {
24719                let agent_id = agent_id.as_str();
24720                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24721                spec.revision =
24722                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24723                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24724                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24725                    adapter: adapter.clone(),
24726                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24727                    launch_override: None,
24728                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24729                });
24730                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24731                    adapter: adapter.clone(),
24732                    launch_override: None,
24733                });
24734                spec.capabilities.adapters.hook = Some(adapter.clone());
24735                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24736                spec.capabilities.adapters.one_shot = Some(adapter.clone());
24737                spec.capabilities.adapters.history = Some(adapter.clone());
24738                spec.capabilities.adapters.resume = Some(adapter.clone());
24739                spec.capabilities.adapters.session_options = Some(adapter.clone());
24740                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24741                spec
24742            })
24743            .collect::<Vec<_>>();
24744        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24745    }
24746
24747    /// The real fleet's manifest is accepted by a real server construction.
24748    ///
24749    /// The count is asserted against `active_registry` itself rather than a
24750    /// literal, so this stays a test that construction succeeds for whatever
24751    /// the fleet is -- which is the property worth guarding -- instead of a
24752    /// second place the fleet size is written down and has to be kept in
24753    /// step with the first.
24754    #[test]
24755    fn node_server_construction_accepts_the_production_manifest() {
24756        let registry = active_registry().unwrap();
24757        let (providers, _) = provider_contract_manifest(&registry).unwrap();
24758        assert_eq!(providers.len(), registry.iter().count());
24759        assert!(!providers.is_empty());
24760        let server = NodeServer::new_with_registry(
24761            construction_test_config("manifest-production-bound"),
24762            registry,
24763        )
24764        .unwrap();
24765        drop(server);
24766    }
24767
24768    #[test]
24769    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24770        let registry = maximum_size_provider_manifest_registry();
24771        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
24772        assert_eq!(providers.len(), 3);
24773        assert_eq!(adapters.len(), 30);
24774        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24775        let error = match NodeServer::new_with_registry(
24776            construction_test_config("manifest-handshake-overflow"),
24777            registry,
24778        ) {
24779            Ok(_) => panic!("oversized negotiated manifest was accepted"),
24780            Err(error) => error,
24781        };
24782        assert!(matches!(
24783            error,
24784            NodeServerError::ProviderContractManifest(message)
24785                if message.contains("authentication binding length")
24786                    && message.contains("8192-byte limit")
24787        ));
24788    }
24789
24790    #[test]
24791    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24792        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24793        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24794        let registry = AgentRegistry::new([spec]).unwrap();
24795        assert!(matches!(
24796            provider_contract_manifest(&registry),
24797            Err(NodeServerError::ProviderContractManifest(message))
24798                if message.contains("exceeds the 128-byte limit")
24799        ));
24800    }
24801
24802    #[test]
24803    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24804        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24805        spec.revision = "invalid revision".to_owned();
24806        let registry = AgentRegistry::new([spec]).unwrap();
24807        assert!(matches!(
24808            provider_contract_manifest(&registry),
24809            Err(NodeServerError::ProviderContractManifest(message))
24810                if message.contains("bounded lowercase ASCII")
24811        ));
24812    }
24813
24814    #[test]
24815    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24816        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24817        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24818        let conflicting_binding = builtin_registry()
24819            .get_by_id("codex")
24820            .unwrap()
24821            .capabilities
24822            .adapters
24823            .one_shot
24824            .clone()
24825            .unwrap();
24826        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24827        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24828        pipe.adapter = transport_binding;
24829        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24830        let registry = AgentRegistry::new([spec]).unwrap();
24831        assert!(matches!(
24832            provider_contract_manifest(&registry),
24833            Err(NodeServerError::ProviderContractManifest(message))
24834                if message.contains("conflicting OneShot adapter bindings")
24835        ));
24836    }
24837
24838    #[test]
24839    fn public_node_failures_expose_only_fixed_categories() {
24840        let provider = failure(
24841            NodeFailureCode::BackendOperationFailed,
24842            r"provider bearer-secret failed at C:\private\session.jsonl",
24843        );
24844        assert_eq!(provider.message, "backend-operation-failed");
24845        assert!(!provider.message.contains("bearer-secret"));
24846        assert!(!provider.message.contains(r"C:\private"));
24847        let persistence = persistence_failure(io::Error::new(
24848            io::ErrorKind::Other,
24849            r"state write failed at C:\private\state-v1.json",
24850        ));
24851        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24852        assert!(!persistence.message.contains(r"C:\private"));
24853
24854        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24855            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24856            assert_eq!(
24857                generic.to_string(),
24858                "node durable state failed: durable-state-load-failed",
24859            );
24860        }
24861    }
24862
24863    #[test]
24864    fn terminal_byte_sequences_are_bounded_before_dispatch() {
24865        let sequence = b"\x1b[1;5D".to_vec();
24866        assert_eq!(
24867            terminal_bytes_action(sequence.clone()).unwrap(),
24868            InputAction::TerminalBytes(sequence),
24869        );
24870        assert_eq!(
24871            terminal_bytes_action(Vec::new()).unwrap_err().code,
24872            NodeFailureCode::InvalidRequest,
24873        );
24874        assert_eq!(
24875            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24876                .unwrap_err()
24877                .code,
24878            NodeFailureCode::InvalidRequest,
24879        );
24880    }
24881
24882    #[test]
24883    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24884        let root = temporary_workspace_root("exclusive-state-owner");
24885        std::fs::create_dir_all(&root).unwrap();
24886        let workspace = WorkspaceConfig::new(
24887            WorkspaceId::new("primary").unwrap(),
24888            &root,
24889        )
24890        .unwrap();
24891        let state_path = root.join("state-v1.json");
24892        let endpoint = format!(
24893            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24894            std::process::id(),
24895            std::time::SystemTime::now()
24896                .duration_since(std::time::UNIX_EPOCH)
24897                .unwrap()
24898                .as_nanos(),
24899        );
24900        let config = NodeServerConfig::new(
24901            endpoint,
24902            "fixture-token",
24903            NodeId::new("node-1").unwrap(),
24904            [workspace],
24905        )
24906        .unwrap()
24907        .with_state_path(&state_path)
24908        .unwrap();
24909
24910        let first = NodeServer::new(config.clone()).unwrap();
24911        let error = match NodeServer::new(config.clone()) {
24912            Ok(_) => panic!("a second node server acquired the same durable state path"),
24913            Err(error) => error,
24914        };
24915        assert!(matches!(
24916            &error,
24917            NodeServerError::DurableState(error)
24918                if error.kind() == io::ErrorKind::WouldBlock
24919        ));
24920        assert_eq!(
24921            error.to_string(),
24922            "node durable state failed: durable-state-lock-failed",
24923        );
24924        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24925        drop(first);
24926        let second = NodeServer::new(config).unwrap();
24927        drop(second);
24928        std::fs::remove_dir_all(root).unwrap();
24929    }
24930
24931    #[test]
24932    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24933        let catalog = active_registry().unwrap();
24934        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24935        let workspace_id = WorkspaceId::new("primary").unwrap();
24936        let workspace = WorkspaceConfig::new(
24937            workspace_id.clone(),
24938            std::env::current_dir().unwrap(),
24939        )
24940        .unwrap();
24941        let shared = NodeShared::new(
24942            handle,
24943            "fixture-token".to_owned(),
24944            NodeId::new("node-1").unwrap(),
24945            vec![workspace.clone()],
24946            vec![agent("claude"), agent("codex"), agent("kimi")],
24947        );
24948        let instance_id = AgentInstanceId(17);
24949        let generation = SessionGeneration(3);
24950        assert!(shared.address_for(instance_id, generation).is_none());
24951        shared.bind_session(instance_id, workspace_id.clone(), generation);
24952        let address = SessionAddress {
24953            workspace_id,
24954            session: SessionKey { instance_id, generation },
24955        };
24956        assert_eq!(
24957            shared.workspace_root(&address.workspace_id).unwrap(),
24958            workspace.canonical_root,
24959        );
24960        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
24961        shared.remove_binding(&address);
24962        assert!(shared.address_for(instance_id, generation).is_none());
24963    }
24964
24965    #[test]
24966    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
24967        let address = SessionAddress {
24968            workspace_id: WorkspaceId::new("primary").unwrap(),
24969            session: SessionKey {
24970                instance_id: AgentInstanceId(7),
24971                generation: SessionGeneration(2),
24972            },
24973        };
24974        let record_id = SessionRecordId::new("record-a").unwrap();
24975        let size = gate4agent_types::TerminalSize {
24976            rows: 24,
24977            columns: 80,
24978        };
24979        let address_requests = [
24980            NodeRequest::Resume {
24981                session: address.clone(),
24982                terminal_size: size,
24983                initial_prompt: None,
24984            },
24985            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
24986            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
24987            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
24988            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
24989            NodeRequest::TerminalControl {
24990                session: address.clone(),
24991                control: TerminalControl::Enter,
24992            },
24993            NodeRequest::Resize { session: address.clone(), size },
24994            NodeRequest::Interrupt { session: address.clone() },
24995            NodeRequest::Stop { session: address.clone(), force: false },
24996            NodeRequest::Remove { session: address },
24997        ];
24998        for request in &address_requests {
24999            assert!(request_requires_open_provider_ids_with(
25000                request,
25001                |_| Some(agent("third-party-agent")),
25002                |_| Some(agent("claude")),
25003            ));
25004            assert!(!request_requires_open_provider_ids_with(
25005                request,
25006                |_| Some(agent("claude")),
25007                |_| Some(agent("claude")),
25008            ));
25009            assert!(request_requires_open_provider_ids_with(
25010                request,
25011                |_| None,
25012                |_| Some(agent("claude")),
25013            ));
25014        }
25015
25016        let record_requests = [
25017            NodeRequest::RenameSessionRecord {
25018                record_id: record_id.clone(),
25019                display_name: "renamed".to_owned(),
25020            },
25021            NodeRequest::ResumeSessionRecord {
25022                record_id: record_id.clone(),
25023                terminal_size: size,
25024                initial_prompt: None,
25025            },
25026            NodeRequest::ForgetSessionRecord { record_id },
25027        ];
25028        for request in &record_requests {
25029            assert!(request_requires_open_provider_ids_with(
25030                request,
25031                |_| Some(agent("claude")),
25032                |_| Some(agent("grok")),
25033            ));
25034            assert!(!request_requires_open_provider_ids_with(
25035                request,
25036                |_| Some(agent("claude")),
25037                |_| Some(agent("kimi")),
25038            ));
25039            assert!(request_requires_open_provider_ids_with(
25040                request,
25041                |_| Some(agent("claude")),
25042                |_| None,
25043            ));
25044        }
25045
25046        assert!(request_requires_open_provider_ids_with(
25047            &NodeRequest::Spawn {
25048                workspace_id: WorkspaceId::new("primary").unwrap(),
25049                provider: agent("third-party-agent"),
25050                mode: SessionMode::Pty,
25051                terminal_size: size,
25052                initial_prompt: None,
25053            },
25054            |_| None,
25055            |_| None,
25056        ));
25057        assert!(!request_requires_open_provider_ids_with(
25058            &NodeRequest::Snapshot,
25059            |_| None,
25060            |_| None,
25061        ));
25062        assert!(request_requires_open_provider_ids_with(
25063            &NodeRequest::ForgetContextPack {
25064                context_id: SpawnContextId::new("ctx-forget").unwrap(),
25065            },
25066            |_| None,
25067            |_| None,
25068        ));
25069    }
25070
25071    #[test]
25072    fn legacy_projection_filters_open_record_replies_and_event_history() {
25073        let catalog = active_registry().unwrap();
25074        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25075        let workspace = WorkspaceConfig::new(
25076            WorkspaceId::new("primary").unwrap(),
25077            std::env::current_dir().unwrap(),
25078        )
25079        .unwrap();
25080        let shared = NodeShared::new(
25081            handle,
25082            "fixture-token".to_owned(),
25083            NodeId::new("node-1").unwrap(),
25084            vec![workspace],
25085            vec![agent("claude"), agent("grok")],
25086        );
25087        let legacy = record("claude", "legacy-record");
25088        let open = record("grok", "open-record");
25089        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25090            record: legacy.clone(),
25091        });
25092        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25093            record: open.clone(),
25094        });
25095        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25096            record_id: legacy.record_id.clone(),
25097        });
25098        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25099            record_id: open.record_id.clone(),
25100        });
25101        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25102        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25103        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25104        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25105
25106        let mut record_reply = ResponseEnvelope {
25107            request_id: 1,
25108            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25109        };
25110        project_response_legacy_provider_ids(&shared, &mut record_reply);
25111        assert!(matches!(
25112            record_reply.result,
25113            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25114        ));
25115
25116        let mut resync = ResponseEnvelope {
25117            request_id: 2,
25118            result: Ok(NodeResponse::Resync {
25119                event_sequence: open_removed.sequence,
25120                oldest_available_sequence: 1,
25121                snapshot: shared.snapshot(),
25122                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25123            }),
25124        };
25125        project_response_legacy_provider_ids(&shared, &mut resync);
25126        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25127            panic!("legacy projection changed the resync response kind");
25128        };
25129        assert!(snapshot.session_records.iter().all(|item| {
25130            provider_id_is_legacy(&item.provider)
25131        }));
25132        assert_eq!(events.len(), 2);
25133        assert!(events.iter().all(|event| matches!(
25134            &event.event,
25135            NodeEvent::SessionRecordUpserted { record }
25136                if provider_id_is_legacy(&record.provider)
25137        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25138    }
25139
25140    #[test]
25141    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25142        let catalog = active_registry().unwrap();
25143        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25144        let workspace_id = WorkspaceId::new("primary").unwrap();
25145        let workspace = WorkspaceConfig::new(
25146            workspace_id.clone(),
25147            std::env::current_dir().unwrap(),
25148        )
25149        .unwrap();
25150        let shared = NodeShared::new(
25151            handle,
25152            "fixture-token".to_owned(),
25153            NodeId::new("node-1").unwrap(),
25154            vec![workspace],
25155            vec![agent("claude")],
25156        );
25157        for index in 0..CONTROL_SESSIONS_MAX {
25158            shared.bind_session(
25159                AgentInstanceId((index + 1) as u64),
25160                workspace_id.clone(),
25161                SessionGeneration(1),
25162            );
25163        }
25164        let error = shared.ensure_binding_capacity().unwrap_err();
25165        assert_eq!(error.code, NodeFailureCode::BackendBusy);
25166    }
25167
25168    #[tokio::test]
25169    async fn provider_spawn_admission_rejects_before_session_mutation() {
25170        let catalog = active_registry().unwrap();
25171        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25172        let workspace = WorkspaceConfig::new(
25173            WorkspaceId::new("primary").unwrap(),
25174            std::env::current_dir().unwrap(),
25175        )
25176        .unwrap();
25177        let mut shared = NodeShared::new(
25178            handle,
25179            "fixture-token".to_owned(),
25180            NodeId::new("node-provider-admission").unwrap(),
25181            vec![workspace],
25182            vec![agent("claude")],
25183        );
25184        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25185            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25186        ])
25187        .unwrap();
25188        let terminal_size = gate4agent_types::TerminalSize {
25189            rows: 24,
25190            columns: 80,
25191        };
25192
25193        let unavailable = shared
25194            .spawn_session(
25195                WorkspaceId::new("primary").unwrap(),
25196                agent("claude"),
25197                SessionMode::Pty,
25198                terminal_size,
25199                None,
25200            )
25201            .await
25202            .unwrap_err();
25203        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25204        assert_eq!(unavailable.message, "backend-operation-failed");
25205
25206        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25207            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25208                agent("claude"),
25209                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25210            ),
25211        ])
25212        .unwrap();
25213        let semantic = shared
25214            .spawn_session(
25215                WorkspaceId::new("primary").unwrap(),
25216                agent("claude"),
25217                SessionMode::Inline,
25218                terminal_size,
25219                None,
25220            )
25221            .await
25222            .unwrap_err();
25223        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25224        assert_eq!(semantic.message, "unsupported-capability");
25225
25226        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25227        assert!(shared
25228            .session_bindings
25229            .lock()
25230            .unwrap_or_else(|poisoned| poisoned.into_inner())
25231            .is_empty());
25232        assert!(shared
25233            .session_records
25234            .lock()
25235            .unwrap_or_else(|poisoned| poisoned.into_inner())
25236            .records
25237            .is_empty());
25238        assert!(shared.handle.snapshot().sessions.is_empty());
25239    }
25240
25241    #[tokio::test]
25242    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25243        let catalog = active_registry().unwrap();
25244        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25245        let workspace_id = WorkspaceId::new("primary").unwrap();
25246        let workspace = WorkspaceConfig::new(
25247            workspace_id.clone(),
25248            std::env::current_dir().unwrap(),
25249        )
25250        .unwrap();
25251        let shared = NodeShared::new(
25252            handle,
25253            "fixture-token".to_owned(),
25254            NodeId::new("node-raw-spawn-record").unwrap(),
25255            vec![workspace],
25256            vec![agent("claude")],
25257        );
25258        let address = SessionAddress {
25259            workspace_id,
25260            session: SessionKey {
25261                instance_id: AgentInstanceId(71),
25262                generation: SessionGeneration::default(),
25263            },
25264        };
25265
25266        let record_id = shared
25267            .bind_spawn_session(
25268                &address,
25269                agent("claude"),
25270                SessionMode::Pty,
25271                ProviderRuntimePolicy::raw_pty(),
25272                SpawnRecordPolicy::Always,
25273                None,
25274            )
25275            .unwrap()
25276            .unwrap();
25277        shared
25278            .handle
25279            .dispatch(shared.prepare_command(ControlCommand::Register {
25280                instance_id: address.session.instance_id,
25281                agent_id: agent("claude"),
25282                transport: TransportKind::Pty,
25283            }))
25284            .unwrap();
25285        runtime.tick().await;
25286
25287        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25288            session.instance_id == address.session.instance_id
25289                && session.generation == address.session.generation
25290        }));
25291        let record = shared.record(&record_id).unwrap();
25292        assert_eq!(record.display_name, "claude #1");
25293        assert_eq!(record.state, ManagedSessionState::Live);
25294        assert_eq!(record.active_session.as_ref(), Some(&address));
25295        assert!(record.provider_session.is_none());
25296        let binding_record_id = shared
25297            .session_bindings
25298            .lock()
25299            .unwrap_or_else(|poisoned| poisoned.into_inner())
25300            .get(&address.session.instance_id)
25301            .and_then(|binding| binding.record_id.clone());
25302        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25303
25304        let renamed = shared
25305            .rename_session_record(&record_id, "raw review".to_owned())
25306            .unwrap();
25307        assert_eq!(renamed.record_id, record_id);
25308        assert_eq!(renamed.display_name, "raw review");
25309        shared.reconcile_managed_record(
25310            &record_id,
25311            &address,
25312            &ControlEvent {
25313                sequence: 1,
25314                command_id: None,
25315                instance_id: address.session.instance_id,
25316                generation: address.session.generation,
25317                event: ControlEventKind::Running { process_id: None },
25318            },
25319            false,
25320            false,
25321        );
25322        let running = shared.record(&record_id).unwrap();
25323        assert_eq!(running.state, ManagedSessionState::Live);
25324        assert_eq!(running.active_session.as_ref(), Some(&address));
25325        assert_eq!(running.display_name, "raw review");
25326
25327        shared.reconcile_managed_record(
25328            &record_id,
25329            &address,
25330            &ControlEvent {
25331                sequence: 2,
25332                command_id: None,
25333                instance_id: address.session.instance_id,
25334                generation: address.session.generation,
25335                event: ControlEventKind::Exited {
25336                    exit_code: Some(0),
25337                    forced: false,
25338                },
25339            },
25340            false,
25341            false,
25342        );
25343        let detached = shared.record(&record_id).unwrap();
25344        assert_eq!(detached.state, ManagedSessionState::Unavailable);
25345        assert!(detached.active_session.is_none());
25346        assert_eq!(detached.display_name, "raw review");
25347        let renamed_detached = shared
25348            .rename_session_record(&record_id, "raw review detached".to_owned())
25349            .unwrap();
25350        assert_eq!(renamed_detached.record_id, record_id);
25351        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25352        assert!(renamed_detached.active_session.is_none());
25353        assert_eq!(renamed_detached.display_name, "raw review detached");
25354        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25355    }
25356
25357    #[test]
25358    fn legacy_raw_spawn_binding_remains_untracked() {
25359        let catalog = active_registry().unwrap();
25360        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25361        let workspace_id = WorkspaceId::new("primary").unwrap();
25362        let workspace = WorkspaceConfig::new(
25363            workspace_id.clone(),
25364            std::env::current_dir().unwrap(),
25365        )
25366        .unwrap();
25367        let shared = NodeShared::new(
25368            handle,
25369            "fixture-token".to_owned(),
25370            NodeId::new("node-legacy-raw-spawn").unwrap(),
25371            vec![workspace],
25372            vec![agent("claude")],
25373        );
25374        let address = SessionAddress {
25375            workspace_id,
25376            session: SessionKey {
25377                instance_id: AgentInstanceId(72),
25378                generation: SessionGeneration::default(),
25379            },
25380        };
25381
25382        let record_id = shared
25383            .bind_spawn_session(
25384                &address,
25385                agent("claude"),
25386                SessionMode::Pty,
25387                ProviderRuntimePolicy::raw_pty(),
25388                SpawnRecordPolicy::ProviderIdentityOnly,
25389                None,
25390            )
25391            .unwrap();
25392
25393        assert!(record_id.is_none());
25394        let binding_record_id = shared
25395            .session_bindings
25396            .lock()
25397            .unwrap_or_else(|poisoned| poisoned.into_inner())
25398            .get(&address.session.instance_id)
25399            .and_then(|binding| binding.record_id.clone());
25400        assert!(binding_record_id.is_none());
25401        assert!(shared.snapshot().session_records.is_empty());
25402    }
25403
25404    #[tokio::test]
25405    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25406        let catalog = active_registry().unwrap();
25407        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25408        let workspace_id = WorkspaceId::new("primary").unwrap();
25409        let workspace = WorkspaceConfig::new(
25410            workspace_id.clone(),
25411            std::env::current_dir().unwrap(),
25412        )
25413        .unwrap();
25414        let mut shared = NodeShared::new(
25415            handle,
25416            "fixture-token".to_owned(),
25417            NodeId::new("node-raw-semantic-gate").unwrap(),
25418            vec![workspace],
25419            vec![agent("claude")],
25420        );
25421        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25422            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25423        ])
25424        .unwrap();
25425        shared
25426            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25427            .unwrap();
25428        let address = SessionAddress {
25429            workspace_id,
25430            session: SessionKey {
25431                instance_id: AgentInstanceId(72),
25432                generation: SessionGeneration::default(),
25433            },
25434        };
25435        shared.bind_session_with_policy(
25436            &address,
25437            ProviderRuntimePolicy::raw_pty(),
25438            None,
25439        );
25440        shared
25441            .handle
25442            .dispatch(shared.prepare_command(ControlCommand::Register {
25443                instance_id: address.session.instance_id,
25444                agent_id: agent("claude"),
25445                transport: TransportKind::Pty,
25446            }))
25447            .unwrap();
25448        runtime.tick().await;
25449        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25450        let revision_before = shared.handle.snapshot().revision;
25451        let history_sequence_before = shared.current_sequence();
25452        let terminal_size = gate4agent_types::TerminalSize {
25453            rows: 24,
25454            columns: 80,
25455        };
25456
25457        for request in [
25458            NodeRequest::Prompt {
25459                session: address.clone(),
25460                text: "prompt".to_owned(),
25461            },
25462            NodeRequest::Paste {
25463                session: address.clone(),
25464                text: "paste".to_owned(),
25465            },
25466            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
25467            // the same semantic capabilities as `Prompt`/`Paste`. A bare
25468            // (promptless) resume is a raw PTY relaunch and IS admitted under
25469            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
25470            // in `provider_runtime.rs`; it is deliberately not exercised here.
25471            NodeRequest::Resume {
25472                session: address.clone(),
25473                terminal_size,
25474                initial_prompt: Some("resume".to_owned()),
25475            },
25476        ] {
25477            let error = process_request_inner(
25478                &shared,
25479                77,
25480                ClientRole::Operator,
25481                request,
25482            )
25483            .await
25484            .unwrap_err();
25485            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25486        }
25487
25488        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25489        assert_eq!(shared.handle.snapshot().revision, revision_before);
25490        assert_eq!(shared.current_sequence(), history_sequence_before);
25491        let bindings = shared
25492            .session_bindings
25493            .lock()
25494            .unwrap_or_else(|poisoned| poisoned.into_inner());
25495        let binding = bindings.get(&address.session.instance_id).unwrap();
25496        assert_eq!(binding.generation, address.session.generation);
25497        assert!(binding.pending_resume.is_none());
25498        assert!(binding.record_id.is_none());
25499    }
25500
25501    #[tokio::test]
25502    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25503        let catalog = active_registry().unwrap();
25504        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25505        let primary = WorkspaceConfig::new(
25506            WorkspaceId::new("primary").unwrap(),
25507            std::env::current_dir().unwrap(),
25508        )
25509        .unwrap();
25510        let shared = NodeShared::new(
25511            handle,
25512            "fixture-token".to_owned(),
25513            NodeId::new("node-1").unwrap(),
25514            vec![primary],
25515            vec![agent("claude")],
25516        );
25517        shared
25518            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25519            .unwrap();
25520        let before = shared.snapshot();
25521        let error = process_request_inner(
25522            &shared,
25523            77,
25524            ClientRole::Operator,
25525            NodeRequest::RegisterWorkspace {
25526                workspace_id: WorkspaceId::new("foreign").unwrap(),
25527                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25528            },
25529        )
25530        .await
25531        .unwrap_err();
25532        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25533        assert_eq!(error.message, "invalid-request");
25534        assert_eq!(shared.snapshot().workspaces, before.workspaces);
25535    }
25536
25537    #[tokio::test]
25538    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25539        let catalog = active_registry().unwrap();
25540        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25541        let primary_id = WorkspaceId::new("primary").unwrap();
25542        let primary = WorkspaceConfig::new(
25543            primary_id.clone(),
25544            std::env::current_dir().unwrap(),
25545        )
25546        .unwrap();
25547        let shared = NodeShared::new(
25548            handle,
25549            "fixture-token".to_owned(),
25550            NodeId::new("node-1").unwrap(),
25551            vec![primary.clone()],
25552            vec![agent("claude")],
25553        );
25554        let secondary_id = WorkspaceId::new("secondary").unwrap();
25555        let secondary = shared
25556            .register_workspace(
25557                secondary_id.clone(),
25558                std::env::temp_dir().to_string_lossy().into_owned(),
25559            )
25560            .await
25561            .unwrap();
25562        assert_eq!(secondary.workspace_id, secondary_id);
25563        assert!(secondary.sessions.is_empty());
25564        assert_eq!(shared.snapshot().workspaces.len(), 2);
25565
25566        let duplicate_id = shared
25567            .register_workspace(
25568                secondary_id.clone(),
25569                primary.canonical_root().to_owned(),
25570            )
25571            .await
25572            .unwrap_err();
25573        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25574        let duplicate_root = shared
25575            .register_workspace(
25576                WorkspaceId::new("duplicate-root").unwrap(),
25577                windows_path_text(&secondary.canonical_root).to_owned(),
25578            )
25579            .await
25580            .unwrap_err();
25581        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25582
25583        let address = SessionAddress {
25584            workspace_id: secondary_id.clone(),
25585            session: SessionKey {
25586                instance_id: AgentInstanceId(41),
25587                generation: SessionGeneration(1),
25588            },
25589        };
25590        shared.bind_session(
25591            address.session.instance_id,
25592            secondary_id.clone(),
25593            address.session.generation,
25594        );
25595        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25596        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25597        shared.remove_binding(&address);
25598        shared.unregister_workspace(&secondary_id).unwrap();
25599        assert_eq!(shared.snapshot().workspaces.len(), 1);
25600
25601        let last = shared.unregister_workspace(&primary_id).unwrap_err();
25602        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25603        let history = shared
25604            .history
25605            .lock()
25606            .unwrap_or_else(|poisoned| poisoned.into_inner());
25607        assert!(matches!(
25608            &history.events[0],
25609            NodeEventEnvelope {
25610                sequence: 1,
25611                event: NodeEvent::WorkspaceAdded { workspace },
25612            } if workspace.workspace_id == secondary_id
25613        ));
25614        assert!(matches!(
25615            &history.events[1],
25616            NodeEventEnvelope {
25617                sequence: 2,
25618                event: NodeEvent::WorkspaceRemoved { workspace_id },
25619            } if workspace_id == &secondary_id
25620        ));
25621    }
25622
25623    #[tokio::test]
25624    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25625        let secondary_root = temporary_workspace_root("provider-session-reference-index");
25626        std::fs::create_dir_all(&secondary_root).unwrap();
25627        let catalog = active_registry().unwrap();
25628        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25629        let primary_id = WorkspaceId::new("primary").unwrap();
25630        let secondary_id = WorkspaceId::new("secondary").unwrap();
25631        let shared = NodeShared::new(
25632            handle,
25633            "fixture-token".to_owned(),
25634            NodeId::new("node-session-reference-index").unwrap(),
25635            vec![
25636                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25637                    .unwrap(),
25638                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25639            ],
25640            vec![agent("claude"), agent("codex")],
25641        );
25642        let identity = ProviderSessionIdentity {
25643            key: gate4agent_types::ProviderSessionKey::SessionId,
25644            id: "provider-session-42".to_owned(),
25645            transcript_path: None,
25646        };
25647        let index_request = |workspace_id: WorkspaceId,
25648                             provider: AgentId,
25649                             identity: ProviderSessionIdentity,
25650                             display_name: &str| {
25651            NodeRequest::IndexProviderSession {
25652                workspace_id,
25653                provider,
25654                identity,
25655                display_name: display_name.to_owned(),
25656            }
25657        };
25658        let request = index_request(
25659            primary_id.clone(),
25660            agent("claude"),
25661            identity.clone(),
25662            "release shepherd",
25663        );
25664        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25665            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25666        }));
25667        let observer = process_request_inner(
25668            &shared,
25669            77,
25670            ClientRole::Observer,
25671            request.clone(),
25672        )
25673        .await
25674        .unwrap_err();
25675        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25676        let uncontrolled = process_request_inner(
25677            &shared,
25678            77,
25679            ClientRole::Operator,
25680            request.clone(),
25681        )
25682        .await
25683        .unwrap_err();
25684        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25685        shared
25686            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25687            .unwrap();
25688
25689        let indexed = process_request_inner(
25690            &shared,
25691            77,
25692            ClientRole::Operator,
25693            request.clone(),
25694        )
25695        .await
25696        .unwrap();
25697        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25698            panic!("provider session reference index returned another response");
25699        };
25700        assert_eq!(record.state, ManagedSessionState::Dormant);
25701        assert_eq!(record.mode, SessionMode::Pty);
25702        assert_eq!(record.workspace_id, primary_id);
25703        assert_eq!(record.provider_session.as_ref(), Some(&identity));
25704        assert!(record.active_session.is_none());
25705        assert!(record.environment_profile.is_none());
25706        assert!(record.bundle.is_none());
25707        assert!(record.context.is_none());
25708
25709        let repeated = process_request_inner(
25710            &shared,
25711            77,
25712            ClientRole::Operator,
25713            index_request(
25714                primary_id.clone(),
25715                agent("claude"),
25716                identity.clone(),
25717                "ignored replacement",
25718            ),
25719        )
25720        .await
25721        .unwrap();
25722        assert!(matches!(
25723            repeated,
25724            NodeResponse::ProviderSessionIndexed { record: ref existing }
25725                if existing.record_id == record.record_id
25726                    && existing.display_name == "release shepherd"
25727        ));
25728        let conflicting_workspace = process_request_inner(
25729            &shared,
25730            77,
25731            ClientRole::Operator,
25732            index_request(
25733                secondary_id,
25734                agent("claude"),
25735                identity.clone(),
25736                "release shepherd",
25737            ),
25738        )
25739        .await
25740        .unwrap_err();
25741        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25742        let conflicting_provider = process_request_inner(
25743            &shared,
25744            77,
25745            ClientRole::Operator,
25746            index_request(
25747                primary_id.clone(),
25748                agent("codex"),
25749                identity.clone(),
25750                "release shepherd",
25751            ),
25752        )
25753        .await
25754        .unwrap_err();
25755        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25756        let transcript_rejected = process_request_inner(
25757            &shared,
25758            77,
25759            ClientRole::Operator,
25760            index_request(
25761                primary_id,
25762                agent("claude"),
25763                ProviderSessionIdentity {
25764                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25765                    ..identity
25766                },
25767                "release shepherd",
25768            ),
25769        )
25770        .await
25771        .unwrap_err();
25772        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25773        let upserts = shared
25774            .history
25775            .lock()
25776            .unwrap_or_else(|poisoned| poisoned.into_inner())
25777            .events
25778            .iter()
25779            .filter(|event| matches!(
25780                &event.event,
25781                NodeEvent::SessionRecordUpserted { record: published }
25782                    if published.record_id == record.record_id
25783            ))
25784            .count();
25785        assert_eq!(upserts, 1);
25786        std::fs::remove_dir_all(secondary_root).unwrap();
25787    }
25788
25789    #[tokio::test]
25790    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25791        let fixture_root = temporary_workspace_root("standalone-independent");
25792        let target = fixture_root.join("independent");
25793        std::fs::create_dir_all(&fixture_root).unwrap();
25794        let catalog = active_registry().unwrap();
25795        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25796        let selected = WorkspaceConfig::new(
25797            WorkspaceId::new("primary").unwrap(),
25798            std::env::current_dir().unwrap(),
25799        )
25800        .unwrap();
25801        let selected_root = selected.canonical_root().to_owned();
25802        let shared = NodeShared::new(
25803            handle,
25804            "fixture-token".to_owned(),
25805            NodeId::new("node-standalone").unwrap(),
25806            vec![selected],
25807            vec![agent("claude")],
25808        );
25809        let workspace_id = WorkspaceId::new("independent").unwrap();
25810        let request = NodeRequest::CreateStandaloneWorkspace {
25811            workspace_id: workspace_id.clone(),
25812            root: opaque_windows_path(target.to_string_lossy().into_owned()),
25813            initial_branch: Some("main".to_owned()),
25814        };
25815        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25816            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25817        }));
25818        assert!(!request_uses_unnegotiated_capability(
25819            &request,
25820            &baseline_capabilities().unwrap(),
25821        ));
25822        assert!(request_uses_unnegotiated_capability(&request, &[]));
25823
25824        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25825            .await
25826            .unwrap_err();
25827        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25828        assert!(!target.exists());
25829        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25830            .await
25831            .unwrap_err();
25832        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25833        assert!(!target.exists());
25834        shared
25835            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25836            .unwrap();
25837
25838        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25839            .await
25840            .unwrap();
25841        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25842            panic!("standalone workspace creation returned another response");
25843        };
25844        assert_eq!(workspace.workspace_id, workspace_id);
25845        assert!(target.join(".git").is_dir());
25846        let branch = run_git_bounded(
25847            target.to_str().unwrap(),
25848            &["branch", "--show-current"],
25849            GIT_OUTPUT_MAX_BYTES,
25850        )
25851        .await
25852        .unwrap();
25853        assert!(branch.success);
25854        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25855        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25856        assert_eq!(worktrees.len(), 1);
25857        let listed_root = WorkspaceConfig::new(
25858            WorkspaceId::new("listed-independent").unwrap(),
25859            &worktrees[0].path,
25860        )
25861        .unwrap();
25862        assert!(platform::roots_equal(
25863            listed_root.canonical_root(),
25864            windows_path_text(&workspace.canonical_root),
25865        ));
25866        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25867        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25868            snapshot.workspace_id == workspace_id
25869                && snapshot.canonical_root == workspace.canonical_root
25870        }));
25871        assert!(shared
25872            .history
25873            .lock()
25874            .unwrap_or_else(|poisoned| poisoned.into_inner())
25875            .events
25876            .iter()
25877            .any(|event| matches!(
25878                &event.event,
25879                NodeEvent::WorkspaceAdded { workspace }
25880                    if workspace.workspace_id == workspace_id
25881            )));
25882        std::fs::remove_dir_all(fixture_root).unwrap();
25883    }
25884
25885    #[tokio::test]
25886    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25887        let fixture_root = temporary_workspace_root("standalone-compensation");
25888        let created_target = fixture_root.join("created-target");
25889        let existing_target = fixture_root.join("existing-target");
25890        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25891        let invalid_state_path = invalid_state_parent.join("state.json");
25892        std::fs::create_dir_all(&existing_target).unwrap();
25893        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25894        let catalog = active_registry().unwrap();
25895        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25896        let primary = WorkspaceConfig::new(
25897            WorkspaceId::new("primary").unwrap(),
25898            std::env::current_dir().unwrap(),
25899        )
25900        .unwrap();
25901        let mut shared = NodeShared::new(
25902            handle,
25903            "fixture-token".to_owned(),
25904            NodeId::new("node-standalone-compensation").unwrap(),
25905            vec![primary],
25906            vec![agent("claude")],
25907        );
25908        shared.state_path = Some(invalid_state_path);
25909
25910        let created_recovery = shared
25911            .create_standalone_workspace(
25912                WorkspaceId::new("compensated").unwrap(),
25913                created_target.to_string_lossy().into_owned(),
25914                Some("main".to_owned()),
25915            )
25916            .await
25917            .unwrap_err();
25918        assert_eq!(
25919            created_recovery.code,
25920            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25921        );
25922        assert!(created_target.join(".git").is_dir());
25923        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25924            workspace.workspace_id.as_str() != "compensated"
25925        }));
25926
25927        let existing_recovery = shared
25928            .create_standalone_workspace(
25929                WorkspaceId::new("restored").unwrap(),
25930                existing_target.to_string_lossy().into_owned(),
25931                None,
25932            )
25933            .await
25934            .unwrap_err();
25935        assert_eq!(
25936            existing_recovery.code,
25937            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25938        );
25939        assert!(existing_target.is_dir());
25940        assert!(existing_target.join(".git").is_dir());
25941        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25942            workspace.workspace_id.as_str() != "restored"
25943        }));
25944
25945        let registered_empty = fixture_root.join("registered-empty");
25946        std::fs::create_dir(&registered_empty).unwrap();
25947        let catalog = active_registry().unwrap();
25948        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25949        let registered = WorkspaceConfig::new(
25950            WorkspaceId::new("registered-empty").unwrap(),
25951            &registered_empty,
25952        )
25953        .unwrap();
25954        let duplicate_shared = NodeShared::new(
25955            handle,
25956            "fixture-token".to_owned(),
25957            NodeId::new("node-standalone-duplicate-root").unwrap(),
25958            vec![registered],
25959            vec![agent("claude")],
25960        );
25961        let duplicate = duplicate_shared
25962            .create_standalone_workspace(
25963                WorkspaceId::new("duplicate-root").unwrap(),
25964                registered_empty.to_string_lossy().into_owned(),
25965                None,
25966            )
25967            .await
25968            .unwrap_err();
25969        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
25970        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
25971        std::fs::remove_dir_all(fixture_root).unwrap();
25972    }
25973
25974    #[test]
25975    fn managed_record_id_collision_preserves_the_existing_record() {
25976        let catalog = active_registry().unwrap();
25977        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25978        let workspace = WorkspaceConfig::new(
25979            WorkspaceId::new("primary").unwrap(),
25980            std::env::current_dir().unwrap(),
25981        )
25982        .unwrap();
25983        let shared = NodeShared::new(
25984            handle,
25985            "fixture-token".to_owned(),
25986            NodeId::new("node-1").unwrap(),
25987            vec![workspace.clone()],
25988            vec![agent("claude")],
25989        );
25990        let record_id = SessionRecordId::new("sr-collision").unwrap();
25991        let original = ManagedSessionRecord {
25992            record_id: record_id.clone(),
25993            display_name: "original".to_owned(),
25994            provider: agent("claude"),
25995            mode: SessionMode::Pty,
25996            state: ManagedSessionState::Dormant,
25997            workspace_id: workspace.workspace_id,
25998            canonical_root: opaque_windows_path(workspace.canonical_root),
25999            provider_session: None,
26000            active_session: None,
26001            environment_profile: None,
26002            bundle: None,
26003            context_id: None,
26004            context: None,
26005            exported_context: None,
26006            task_binding: None,
26007            created_at_unix_ms: 1,
26008            updated_at_unix_ms: 1,
26009            last_error: None,
26010        };
26011        shared.insert_record(original.clone()).unwrap();
26012        let mut collision = original.clone();
26013        collision.display_name = "replacement".to_owned();
26014        let error = shared.insert_record(collision).unwrap_err();
26015        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26016        assert_eq!(shared.record(&record_id).unwrap(), original);
26017    }
26018
26019    #[test]
26020    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26021        let catalog = active_registry().unwrap();
26022        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26023        let workspace = WorkspaceConfig::new(
26024            WorkspaceId::new("primary").unwrap(),
26025            std::env::current_dir().unwrap(),
26026        )
26027        .unwrap();
26028        let shared = NodeShared::new(
26029            handle,
26030            "fixture-token".to_owned(),
26031            NodeId::new("node-1").unwrap(),
26032            vec![workspace.clone()],
26033            vec![agent("claude")],
26034        );
26035        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26036        shared
26037            .insert_record(ManagedSessionRecord {
26038                record_id: record_id.clone(),
26039                display_name: "runtime error".to_owned(),
26040                provider: agent("claude"),
26041                mode: SessionMode::Pty,
26042                state: ManagedSessionState::IdentityPending,
26043                workspace_id: workspace.workspace_id,
26044                canonical_root: opaque_windows_path(workspace.canonical_root),
26045                provider_session: None,
26046                active_session: None,
26047                environment_profile: None,
26048                bundle: None,
26049                context_id: None,
26050                context: None,
26051                exported_context: None,
26052                task_binding: None,
26053                created_at_unix_ms: 1,
26054                updated_at_unix_ms: 1,
26055                last_error: None,
26056            })
26057            .unwrap();
26058
26059        shared
26060            .mark_record_error(
26061                &record_id,
26062                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26063            )
26064            .unwrap();
26065        let record = shared.record(&record_id).unwrap();
26066        assert_eq!(record.state, ManagedSessionState::Unavailable);
26067        assert_eq!(
26068            record.last_error.as_deref(),
26069            Some("provider-runtime-failed"),
26070        );
26071        let snapshot = shared.snapshot();
26072        assert_eq!(snapshot.session_records, vec![record]);
26073    }
26074
26075    /// Regression coverage for the "operator sees `deadline` instead of the
26076    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
26077    /// able to learn a command's asynchronous kernel rejection (here,
26078    /// specifically classified as `UnsupportedTransport`) exactly once, and
26079    /// must not still find it after that -- proving the fast path this
26080    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
26081    /// loop) drains the reason instead of leaving it to be rediscovered (or
26082    /// missed) on a later poll.
26083    #[test]
26084    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26085        let catalog = active_registry().unwrap();
26086        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26087        let workspace = WorkspaceConfig::new(
26088            WorkspaceId::new("primary").unwrap(),
26089            std::env::current_dir().unwrap(),
26090        )
26091        .unwrap();
26092        let shared = NodeShared::new(
26093            handle,
26094            "fixture-token".to_owned(),
26095            NodeId::new("node-1").unwrap(),
26096            vec![workspace],
26097            vec![agent("claude")],
26098        );
26099
26100        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26101
26102        shared.record_rejected_command(
26103            CommandId(1),
26104            "agent 'claude' does not support transport Acp".to_owned(),
26105            true,
26106        );
26107        // An unrelated command_id must not see another command's reason.
26108        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26109
26110        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26111        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26112        assert!(reason.unsupported_transport);
26113
26114        // Consumed: a second read for the same command_id finds nothing.
26115        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26116
26117        // A rejection unrelated to transport support keeps its own
26118        // classification distinct, so the reader can choose the generic
26119        // `BackendOperationFailed` code instead of misreporting
26120        // `UnsupportedTransport`.
26121        shared.record_rejected_command(
26122            CommandId(3),
26123            "backend busy".to_owned(),
26124            false,
26125        );
26126        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26127        assert!(!unclassified.unsupported_transport);
26128    }
26129
26130    #[test]
26131    fn provider_identity_update_is_ignored_without_exact_session_policy() {
26132        let catalog = active_registry().unwrap();
26133        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26134        let workspace = WorkspaceConfig::new(
26135            WorkspaceId::new("primary").unwrap(),
26136            std::env::current_dir().unwrap(),
26137        )
26138        .unwrap();
26139        let shared = NodeShared::new(
26140            handle,
26141            "fixture-token".to_owned(),
26142            NodeId::new("node-identity-policy").unwrap(),
26143            vec![workspace.clone()],
26144            vec![agent("claude")],
26145        );
26146        let address = SessionAddress {
26147            workspace_id: workspace.workspace_id.clone(),
26148            session: SessionKey {
26149                instance_id: AgentInstanceId(90),
26150                generation: SessionGeneration(1),
26151            },
26152        };
26153        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26154        shared
26155            .insert_record(ManagedSessionRecord {
26156                record_id: record_id.clone(),
26157                display_name: "policy denied".to_owned(),
26158                provider: agent("claude"),
26159                mode: SessionMode::Pty,
26160                state: ManagedSessionState::IdentityPending,
26161                workspace_id: workspace.workspace_id,
26162                canonical_root: opaque_windows_path(workspace.canonical_root),
26163                provider_session: None,
26164                active_session: Some(address.clone()),
26165                environment_profile: None,
26166                bundle: None,
26167                context_id: None,
26168                context: None,
26169                exported_context: None,
26170                task_binding: None,
26171                created_at_unix_ms: 1,
26172                updated_at_unix_ms: 1,
26173                last_error: None,
26174            })
26175            .unwrap();
26176        let event = ControlEvent {
26177            sequence: 1,
26178            command_id: None,
26179            instance_id: address.session.instance_id,
26180            generation: address.session.generation,
26181            event: ControlEventKind::ProviderEvent {
26182                sequence: 1,
26183                source: gate4agent_types::ProviderSource {
26184                    family: AdapterFamily::PtySemantic,
26185                    binding: AdapterBinding::new(
26186                        gate4agent_types::AdapterId::new("claude").unwrap(),
26187                        "fixture/v1",
26188                        gate4agent_types::AdapterVerification::SyntheticFixture,
26189                    )
26190                    .unwrap(),
26191                },
26192                source_sequence: 1,
26193                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26194                    identity: gate4agent_types::ProviderSessionIdentity {
26195                        key: gate4agent_types::ProviderSessionKey::SessionId,
26196                        id: "provider-session".to_owned(),
26197                        transcript_path: None,
26198                    },
26199                },
26200            },
26201        };
26202
26203        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26204
26205        let record = shared.record(&record_id).unwrap();
26206        assert!(record.provider_session.is_none());
26207        assert_eq!(record.state, ManagedSessionState::IdentityPending);
26208        assert_eq!(record.active_session.as_ref(), Some(&address));
26209    }
26210
26211    #[test]
26212    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26213        let secondary_root = temporary_workspace_root("identity-scope");
26214        std::fs::create_dir_all(&secondary_root).unwrap();
26215        let catalog = active_registry().unwrap();
26216        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26217        let primary = WorkspaceConfig::new(
26218            WorkspaceId::new("primary").unwrap(),
26219            std::env::current_dir().unwrap(),
26220        )
26221        .unwrap();
26222        let secondary = WorkspaceConfig::new(
26223            WorkspaceId::new("secondary").unwrap(),
26224            &secondary_root,
26225        )
26226        .unwrap();
26227        let shared = NodeShared::new(
26228            handle,
26229            "fixture-token".to_owned(),
26230            NodeId::new("node-1").unwrap(),
26231            vec![primary.clone(), secondary.clone()],
26232            vec![agent("claude")],
26233        );
26234        let identity = gate4agent_types::ProviderSessionIdentity {
26235            key: gate4agent_types::ProviderSessionKey::SessionId,
26236            id: "provider-session".to_owned(),
26237            transcript_path: None,
26238        };
26239        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26240        shared
26241            .insert_record(ManagedSessionRecord {
26242                record_id: dormant_id.clone(),
26243                display_name: "dormant".to_owned(),
26244                provider: agent("claude"),
26245                mode: SessionMode::Pty,
26246                state: ManagedSessionState::Dormant,
26247                workspace_id: primary.workspace_id,
26248                canonical_root: opaque_windows_path(primary.canonical_root),
26249                provider_session: Some(identity.clone()),
26250                active_session: None,
26251                environment_profile: None,
26252                bundle: None,
26253                context_id: None,
26254                context: None,
26255                exported_context: None,
26256                task_binding: None,
26257                created_at_unix_ms: 1,
26258                updated_at_unix_ms: 1,
26259                last_error: None,
26260            })
26261            .unwrap();
26262        let address = SessionAddress {
26263            workspace_id: secondary.workspace_id.clone(),
26264            session: SessionKey {
26265                instance_id: AgentInstanceId(91),
26266                generation: SessionGeneration(1),
26267            },
26268        };
26269        let current_id = SessionRecordId::new("sr-current").unwrap();
26270        shared
26271            .insert_record(ManagedSessionRecord {
26272                record_id: current_id.clone(),
26273                display_name: "current".to_owned(),
26274                provider: agent("claude"),
26275                mode: SessionMode::Pty,
26276                state: ManagedSessionState::IdentityPending,
26277                workspace_id: secondary.workspace_id,
26278                canonical_root: opaque_windows_path(secondary.canonical_root),
26279                provider_session: None,
26280                active_session: Some(address.clone()),
26281                environment_profile: None,
26282                bundle: None,
26283                context_id: None,
26284                context: None,
26285                exported_context: None,
26286                task_binding: None,
26287                created_at_unix_ms: 2,
26288                updated_at_unix_ms: 2,
26289                last_error: None,
26290            })
26291            .unwrap();
26292        shared.bind_managed_session(
26293            &address,
26294            current_id.clone(),
26295            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26296            None,
26297        );
26298        let event = ControlEvent {
26299            sequence: 1,
26300            command_id: None,
26301            instance_id: address.session.instance_id,
26302            generation: address.session.generation,
26303            event: ControlEventKind::ProviderEvent {
26304                sequence: 1,
26305                source: gate4agent_types::ProviderSource {
26306                    family: gate4agent_types::AdapterFamily::PtySemantic,
26307                    binding: gate4agent_types::AdapterBinding::new(
26308                        gate4agent_types::AdapterId::new("claude").unwrap(),
26309                        "fixture/v1",
26310                        gate4agent_types::AdapterVerification::SyntheticFixture,
26311                    )
26312                    .unwrap(),
26313                },
26314                source_sequence: 1,
26315                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26316                    identity,
26317                },
26318            },
26319        };
26320        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
26321
26322        let binding = shared
26323            .session_bindings
26324            .lock()
26325            .unwrap_or_else(|poisoned| poisoned.into_inner())
26326            .get(&address.session.instance_id)
26327            .cloned()
26328            .unwrap();
26329        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
26330        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26331        let current = shared.record(&current_id).unwrap();
26332        assert_eq!(current.state, ManagedSessionState::Unavailable);
26333        assert_eq!(current.active_session.as_ref(), Some(&address));
26334        assert_eq!(
26335            current.last_error.as_deref(),
26336            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26337        );
26338        std::fs::remove_dir_all(secondary_root).unwrap();
26339    }
26340
26341    #[tokio::test]
26342    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26343        let root = temporary_workspace_root("semantic-provider-session");
26344        std::fs::create_dir_all(&root).unwrap();
26345        let state_path = root.join("state-v1.json");
26346        let catalog = active_registry().unwrap();
26347        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26348        let workspace = WorkspaceConfig::new(
26349            WorkspaceId::new("primary").unwrap(),
26350            &root,
26351        )
26352        .unwrap();
26353        let node_id = NodeId::new("node-semantic-identity").unwrap();
26354        let shared = NodeShared::new_with_incarnation(
26355            handle,
26356            "fixture-token".to_owned(),
26357            node_id.clone(),
26358            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26359            vec![workspace.clone()],
26360            vec![agent("claude")],
26361            ProviderRuntimeStatuses::default(),
26362            None,
26363            Vec::new(),
26364            Vec::new(),
26365            SpawnProfileRegistry::default(),
26366            None,
26367            None,
26368            None,
26369            Some(state_path.clone()),
26370            Vec::new(),
26371            Vec::new(),
26372            Vec::new(),
26373            Vec::new(),
26374            None,
26375            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26376        );
26377        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26378            key: gate4agent_types::ProviderSessionKey::SessionId,
26379            id: "provider-session".to_owned(),
26380            transcript_path: None,
26381        };
26382        let durable_id = SessionRecordId::new("sr-durable").unwrap();
26383        shared
26384            .insert_record(ManagedSessionRecord {
26385                record_id: durable_id.clone(),
26386                display_name: "durable".to_owned(),
26387                provider: agent("claude"),
26388                mode: SessionMode::Pty,
26389                state: ManagedSessionState::Dormant,
26390                workspace_id: workspace.workspace_id.clone(),
26391                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26392                provider_session: Some(persisted_identity.clone()),
26393                active_session: None,
26394                environment_profile: None,
26395                bundle: None,
26396                context_id: None,
26397                context: None,
26398                exported_context: None,
26399                task_binding: None,
26400                created_at_unix_ms: 1,
26401                updated_at_unix_ms: 1,
26402                last_error: None,
26403            })
26404            .unwrap();
26405        let address = SessionAddress {
26406            workspace_id: workspace.workspace_id.clone(),
26407            session: SessionKey {
26408                instance_id: AgentInstanceId(92),
26409                generation: SessionGeneration(2),
26410            },
26411        };
26412        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26413        shared
26414            .insert_record(ManagedSessionRecord {
26415                record_id: pending_id.clone(),
26416                display_name: "pending".to_owned(),
26417                provider: agent("claude"),
26418                mode: SessionMode::Pty,
26419                state: ManagedSessionState::IdentityPending,
26420                workspace_id: workspace.workspace_id.clone(),
26421                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26422                provider_session: None,
26423                active_session: Some(address.clone()),
26424                environment_profile: None,
26425                bundle: None,
26426                context_id: None,
26427                context: None,
26428                exported_context: None,
26429                task_binding: None,
26430                created_at_unix_ms: 2,
26431                updated_at_unix_ms: 2,
26432                last_error: None,
26433            })
26434            .unwrap();
26435        shared.bind_managed_session(
26436            &address,
26437            pending_id.clone(),
26438            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26439            None,
26440        );
26441        let observed_identity = gate4agent_types::ProviderSessionIdentity {
26442            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26443            ..persisted_identity
26444        };
26445        let event = ControlEvent {
26446            sequence: 1,
26447            command_id: None,
26448            instance_id: address.session.instance_id,
26449            generation: address.session.generation,
26450            event: ControlEventKind::ProviderEvent {
26451                sequence: 1,
26452                source: gate4agent_types::ProviderSource {
26453                    family: gate4agent_types::AdapterFamily::PtySemantic,
26454                    binding: gate4agent_types::AdapterBinding::new(
26455                        gate4agent_types::AdapterId::new("claude").unwrap(),
26456                        "fixture/v1",
26457                        gate4agent_types::AdapterVerification::SyntheticFixture,
26458                    )
26459                    .unwrap(),
26460                },
26461                source_sequence: 1,
26462                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26463                    identity: observed_identity.clone(),
26464                },
26465            },
26466        };
26467        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26468
26469        let snapshot = shared.snapshot();
26470        assert_eq!(snapshot.session_records.len(), 1);
26471        let live = &snapshot.session_records[0];
26472        assert_eq!(live.record_id, durable_id);
26473        assert_eq!(live.state, ManagedSessionState::Live);
26474        assert_eq!(live.active_session.as_ref(), Some(&address));
26475        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26476        let binding = shared
26477            .session_bindings
26478            .lock()
26479            .unwrap_or_else(|poisoned| poisoned.into_inner())
26480            .get(&address.session.instance_id)
26481            .cloned()
26482            .unwrap();
26483        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26484
26485        let mut duplicate = live.clone();
26486        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26487        duplicate.display_name = "duplicate".to_owned();
26488        duplicate.state = ManagedSessionState::Dormant;
26489        duplicate.active_session = None;
26490        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26491        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26492        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26493        assert_eq!(shared.snapshot().session_records.len(), 1);
26494
26495        let terminal_size = gate4agent_types::TerminalSize {
26496            rows: 24,
26497            columns: 80,
26498        };
26499        let (first_busy, second_busy) = tokio::join!(
26500            shared.resume_session_record(&durable_id, terminal_size, None),
26501            shared.resume_session_record(&durable_id, terminal_size, None),
26502        );
26503        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26504            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26505            assert_eq!(busy.message, "session-record-busy");
26506        }
26507
26508        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26509        assert_eq!(loaded.records.len(), 1);
26510        assert_eq!(loaded.records[0].record_id, durable_id);
26511        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26512        assert_eq!(
26513            loaded.records[0]
26514                .provider_session
26515                .as_ref()
26516                .unwrap()
26517                .transcript_path,
26518            None,
26519        );
26520        let catalog = active_registry().unwrap();
26521        let (reloaded_handle, _reloaded_runtime) =
26522            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26523        let reloaded = NodeShared::new_with_incarnation(
26524            reloaded_handle,
26525            "fixture-token".to_owned(),
26526            node_id,
26527            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26528            vec![workspace],
26529            vec![agent("claude")],
26530            ProviderRuntimeStatuses::default(),
26531            None,
26532            Vec::new(),
26533            Vec::new(),
26534            SpawnProfileRegistry::default(),
26535            None,
26536            None,
26537            None,
26538            None,
26539            loaded.records,
26540            loaded.managed_worktrees,
26541            loaded.managed_worktree_tombstones,
26542            loaded.materializations,
26543            loaded.warning,
26544            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26545        );
26546        assert_eq!(reloaded.snapshot().session_records.len(), 1);
26547        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26548        std::fs::remove_dir_all(root).unwrap();
26549    }
26550
26551    #[tokio::test]
26552    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26553        let secondary_root = temporary_workspace_root("workspace-restore");
26554        let other_root = temporary_workspace_root("workspace-conflict");
26555        std::fs::create_dir_all(&secondary_root).unwrap();
26556        std::fs::create_dir_all(&other_root).unwrap();
26557        let catalog = active_registry().unwrap();
26558        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26559        let primary = WorkspaceConfig::new(
26560            WorkspaceId::new("primary").unwrap(),
26561            std::env::current_dir().unwrap(),
26562        )
26563        .unwrap();
26564        let secondary = WorkspaceConfig::new(
26565            WorkspaceId::new("secondary").unwrap(),
26566            &secondary_root,
26567        )
26568        .unwrap();
26569        let shared = NodeShared::new(
26570            handle,
26571            "fixture-token".to_owned(),
26572            NodeId::new("node-1").unwrap(),
26573            vec![primary, secondary.clone()],
26574            vec![agent("claude")],
26575        );
26576        let record_id = SessionRecordId::new("sr-workspace").unwrap();
26577        shared
26578            .insert_record(ManagedSessionRecord {
26579                record_id: record_id.clone(),
26580                display_name: "workspace record".to_owned(),
26581                provider: agent("claude"),
26582                mode: SessionMode::Pty,
26583                state: ManagedSessionState::Dormant,
26584                workspace_id: secondary.workspace_id.clone(),
26585                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26586                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26587                    key: gate4agent_types::ProviderSessionKey::SessionId,
26588                    id: "provider-session".to_owned(),
26589                    transcript_path: None,
26590                }),
26591                active_session: None,
26592                environment_profile: None,
26593                bundle: None,
26594                context_id: None,
26595                context: None,
26596                exported_context: None,
26597                task_binding: None,
26598                created_at_unix_ms: 1,
26599                updated_at_unix_ms: 1,
26600                last_error: None,
26601            })
26602            .unwrap();
26603        shared.unregister_workspace(&secondary.workspace_id).unwrap();
26604        let unavailable = shared.record(&record_id).unwrap();
26605        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26606        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26607
26608        let conflict = shared
26609            .register_workspace(
26610                secondary.workspace_id.clone(),
26611                other_root.to_string_lossy().into_owned(),
26612            )
26613            .await
26614            .unwrap_err();
26615        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26616        shared
26617            .register_workspace(
26618                secondary.workspace_id,
26619                secondary_root.to_string_lossy().into_owned(),
26620            )
26621            .await
26622            .unwrap();
26623        let restored = shared.record(&record_id).unwrap();
26624        assert_eq!(restored.state, ManagedSessionState::Dormant);
26625        assert!(restored.last_error.is_none());
26626        std::fs::remove_dir_all(secondary_root).unwrap();
26627        std::fs::remove_dir_all(other_root).unwrap();
26628    }
26629
26630    #[test]
26631    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26632        let error = validate_workspace_request_root(
26633            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26634        )
26635        .unwrap_err();
26636        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26637        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26638        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26639    }
26640
26641    #[test]
26642    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26643        let catalog = active_registry().unwrap();
26644        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26645        let workspace = WorkspaceConfig::new(
26646            WorkspaceId::new("primary").unwrap(),
26647            std::env::current_dir().unwrap(),
26648        )
26649        .unwrap();
26650        let shared = NodeShared::new(
26651            handle,
26652            "fixture-token".to_owned(),
26653            NodeId::new("node-1").unwrap(),
26654            vec![workspace],
26655            vec![agent("claude")],
26656        );
26657        let publishers = 4_u64;
26658        let events_per_publisher = 256_u64;
26659        let start = std::sync::Barrier::new(publishers as usize + 1);
26660        let finished = AtomicU64::new(0);
26661
26662        std::thread::scope(|scope| {
26663            for _ in 0..publishers {
26664                scope.spawn(|| {
26665                    start.wait();
26666                    for _ in 0..events_per_publisher {
26667                        shared.publish(NodeEvent::ControllerChanged { controller: None });
26668                    }
26669                    finished.fetch_add(1, Ordering::Release);
26670                });
26671            }
26672            start.wait();
26673            while finished.load(Ordering::Acquire) != publishers {
26674                assert_resync_history_is_linearized(shared.resync(0));
26675                std::thread::yield_now();
26676            }
26677        });
26678        let response = shared.resync(0);
26679        assert_resync_history_is_linearized(response.clone());
26680        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26681            unreachable!("resync helper returned another response");
26682        };
26683        assert_eq!(event_sequence, publishers * events_per_publisher);
26684        assert_eq!(events.len() as u64, event_sequence);
26685    }
26686
26687    fn assert_resync_history_is_linearized(response: NodeResponse) {
26688        let NodeResponse::Resync { event_sequence, events, .. } = response else {
26689            unreachable!("resync helper returned another response");
26690        };
26691        assert!(events.windows(2).all(|pair| {
26692            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26693        }));
26694        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26695    }
26696
26697    #[test]
26698    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26699        assert_eq!(
26700            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26701            r"C:\repo\worktree",
26702        );
26703        assert_eq!(
26704            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26705            r"\\server\share\repo",
26706        );
26707        assert!(validate_workspace_root(
26708            &WorkspaceId::new("network").unwrap(),
26709            r"\\server\share\repo",
26710        )
26711        .is_err());
26712        let workspace = WorkspaceConfig::new(
26713            WorkspaceId::new("primary").unwrap(),
26714            std::env::current_dir().unwrap(),
26715        )
26716        .unwrap();
26717        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26718    }
26719
26720    #[test]
26721    fn resume_rebinds_only_on_the_expected_authorized_generation() {
26722        let catalog = active_registry().unwrap();
26723        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26724        let workspace_id = WorkspaceId::new("primary").unwrap();
26725        let workspace = WorkspaceConfig::new(
26726            workspace_id.clone(),
26727            std::env::current_dir().unwrap(),
26728        )
26729        .unwrap();
26730        let shared = NodeShared::new(
26731            handle,
26732            "fixture-token".to_owned(),
26733            NodeId::new("node-1").unwrap(),
26734            vec![workspace],
26735            vec![agent("claude")],
26736        );
26737        let instance_id = AgentInstanceId(17);
26738        let original = SessionAddress {
26739            workspace_id,
26740            session: SessionKey {
26741                instance_id,
26742                generation: SessionGeneration(3),
26743            },
26744        };
26745        shared.bind_session(
26746            instance_id,
26747            original.workspace_id.clone(),
26748            original.session.generation,
26749        );
26750        shared
26751            .arm_resume(
26752                &original,
26753                CommandId(41),
26754                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26755            )
26756            .unwrap();
26757
26758        shared.publish_control(ControlEvent {
26759            sequence: 1,
26760            command_id: None,
26761            instance_id,
26762            generation: SessionGeneration(4),
26763            event: ControlEventKind::Running { process_id: None },
26764        });
26765        assert_eq!(
26766            shared.address_for(instance_id, SessionGeneration(3)),
26767            Some(original.clone()),
26768        );
26769        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26770
26771        shared.publish_control(ControlEvent {
26772            sequence: 2,
26773            command_id: Some(CommandId(42)),
26774            instance_id,
26775            generation: SessionGeneration(3),
26776            event: ControlEventKind::CommandRejected {
26777                message: "unrelated command".to_owned(),
26778            },
26779        });
26780
26781        shared.publish_control(ControlEvent {
26782            sequence: 3,
26783            command_id: None,
26784            instance_id,
26785            generation: SessionGeneration(4),
26786            event: ControlEventKind::ResumeAuthorized {
26787                session: gate4agent_types::ResumeSessionSummary {
26788                    key: gate4agent_types::ProviderSessionKey::SessionId,
26789                    id: "fixture-session".to_owned(),
26790                },
26791            },
26792        });
26793        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26794        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26795        assert_eq!(rebound.workspace_id, original.workspace_id);
26796        let history = shared
26797            .history
26798            .lock()
26799            .unwrap_or_else(|poisoned| poisoned.into_inner());
26800        assert!(history.events.iter().any(|envelope| matches!(
26801            &envelope.event,
26802            NodeEvent::Control { address, event }
26803                if address == &rebound
26804                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26805        )));
26806    }
26807
26808    #[test]
26809    fn all_node_pty_prompts_use_bracketed_paste_framing() {
26810        for agent in ["claude", "codex", "kimi"] {
26811            assert_eq!(
26812                prompt_framing(&AgentId::new(agent).unwrap()),
26813                PromptFraming::BracketedPaste
26814            );
26815        }
26816    }
26817
26818    fn generous_walk_deadline() -> Instant {
26819        Instant::now() + Duration::from_secs(30)
26820    }
26821
26822    #[test]
26823    fn workspace_tree_is_relative_and_skips_heavy_directories() {
26824        let root = temporary_workspace_root("tree");
26825        std::fs::create_dir_all(root.join("src")).unwrap();
26826        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26827        std::fs::create_dir_all(root.join("target/debug")).unwrap();
26828        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26829        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26830        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26831        // A renamed/agent-scoped build directory (`target-a2`-style, per the
26832        // real repository's own build layout) that carries the Cache
26833        // Directory Tagging Specification marker cargo writes into every
26834        // `--target-dir` it creates: must be skipped even though its name
26835        // does not exactly match `target`.
26836        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26837        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26838        // A directory that merely *starts with* `target` but carries no
26839        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
26840        // on the name alone, so this stays in the response.
26841        std::fs::create_dir_all(root.join("target-notes")).unwrap();
26842        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26843
26844        let (entries, truncated, _budget) =
26845            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26846        assert!(!truncated);
26847        assert!(entries.iter().any(|entry| {
26848            entry.relative_path.as_utf8() == Some("src")
26849                && entry.kind == WorkspaceEntryKind::Directory
26850        }));
26851        assert!(entries.iter().any(|entry| {
26852            entry.relative_path.as_utf8() == Some("src/lib.rs")
26853                && entry.kind == WorkspaceEntryKind::File
26854        }));
26855        assert!(entries.iter().any(|entry| {
26856            entry.relative_path.as_utf8() == Some("target-notes")
26857                && entry.kind == WorkspaceEntryKind::Directory
26858        }));
26859        assert!(entries.iter().any(|entry| {
26860            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26861                && entry.kind == WorkspaceEntryKind::File
26862        }));
26863        assert!(entries.iter().all(|entry| {
26864            let path = entry.relative_path.as_utf8().unwrap();
26865            !path.starts_with(".git")
26866                && !(path == "target" || path.starts_with("target/"))
26867                && !(path == "target-a2" || path.starts_with("target-a2/"))
26868                && !path.starts_with("node_modules")
26869        }));
26870
26871        std::fs::remove_dir_all(&root).unwrap();
26872    }
26873
26874    #[test]
26875    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26876        let root = temporary_workspace_root("tree-entry-cap");
26877        std::fs::create_dir_all(&root).unwrap();
26878        for index in 0..20 {
26879            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26880        }
26881
26882        let (entries, truncated, budget) =
26883            collect_workspace_entries(&root, generous_walk_deadline(), 5);
26884        assert!(truncated);
26885        assert!(budget.entry_cap_exceeded);
26886        assert!(!budget.time_budget_exceeded);
26887        assert!(entries.len() <= 5);
26888
26889        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26890            .expect("an entry-cap-truncated walk must report a truncation marker");
26891        assert!(inspection_truncation.walk_entry_cap_exceeded);
26892        assert!(!inspection_truncation.walk_time_budget_exceeded);
26893        assert!(!inspection_truncation.git_time_budget_exceeded);
26894        assert!(inspection_truncation.entries_visited >= 5);
26895
26896        std::fs::remove_dir_all(&root).unwrap();
26897    }
26898
26899    #[test]
26900    fn workspace_walk_within_budget_reports_no_truncation() {
26901        let root = temporary_workspace_root("tree-no-truncation");
26902        std::fs::create_dir_all(&root).unwrap();
26903        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26904
26905        let (_entries, truncated, budget) =
26906            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26907        assert!(!truncated);
26908        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26909
26910        std::fs::remove_dir_all(&root).unwrap();
26911    }
26912
26913    #[test]
26914    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26915        let root = temporary_workspace_root("tree-prefix-siblings");
26916        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26917        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26918        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26919        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26920
26921        let (entries, truncated, _budget) =
26922            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26923        assert!(!truncated);
26924        let paths = entries
26925            .iter()
26926            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26927            .collect::<Vec<_>>();
26928        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26929        assert_eq!(
26930            paths,
26931            vec![
26932                "gate4agent-c2".to_owned(),
26933                "gate4agent-c2-client".to_owned(),
26934                "gate4agent-c2-client/lib.rs".to_owned(),
26935                "gate4agent-c2/src".to_owned(),
26936                "gate4agent-c2/src/lib.rs".to_owned(),
26937            ],
26938        );
26939
26940        std::fs::remove_dir_all(&root).unwrap();
26941    }
26942
26943    #[test]
26944    fn git_parsers_cap_status_and_commit_results() {
26945        let mut status_output = Vec::new();
26946        for index in 0..=GIT_STATUS_MAX_ENTRIES {
26947            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26948        }
26949        let mut snapshot = git_snapshot_for_parser();
26950        parse_git_status(&status_output, &mut snapshot);
26951        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26952        assert!(snapshot.truncated);
26953        assert_eq!(snapshot.status[0].index_status, " ");
26954        assert_eq!(snapshot.status[0].worktree_status, "M");
26955
26956        snapshot.truncated = false;
26957        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26958            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
26959            .collect::<String>();
26960        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
26961        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
26962        assert!(snapshot.truncated);
26963    }
26964
26965    #[test]
26966    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
26967        let mut snapshot = git_snapshot_for_parser();
26968        parse_git_status(
26969            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
26970            &mut snapshot,
26971        );
26972
26973        assert!(!snapshot.truncated);
26974        assert_eq!(snapshot.status.len(), 3);
26975        assert_eq!(snapshot.status[0].index_status, "R");
26976        assert_eq!(snapshot.status[0].worktree_status, " ");
26977        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
26978        assert_eq!(
26979            snapshot.status[0]
26980                .previous_path
26981                .as_ref()
26982                .and_then(RepositoryPath::as_utf8),
26983            Some("src/previous name.rs"),
26984        );
26985        assert_eq!(snapshot.status[1].index_status, " ");
26986        assert_eq!(snapshot.status[1].worktree_status, "C");
26987        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
26988        assert_eq!(
26989            snapshot.status[1]
26990                .previous_path
26991                .as_ref()
26992                .and_then(RepositoryPath::as_utf8),
26993            Some("src/original.rs"),
26994        );
26995        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
26996        assert!(snapshot.status[2].previous_path.is_none());
26997    }
26998
26999    #[test]
27000    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27001        let mut snapshot = git_snapshot_for_parser();
27002        parse_git_status(
27003            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
27004            &mut snapshot,
27005        );
27006
27007        assert!(snapshot.truncated);
27008        assert_eq!(snapshot.status.len(), 1);
27009        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27010        assert!(snapshot.status[0].previous_path.is_none());
27011    }
27012
27013    #[tokio::test]
27014    async fn observer_can_browse_host_directories_without_controller() {
27015        let root = temporary_workspace_root("host-directory-observer");
27016        std::fs::create_dir_all(root.join("directory-b")).unwrap();
27017        std::fs::create_dir_all(root.join("directory-a")).unwrap();
27018        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27019        let workspace_id = WorkspaceId::new("primary").unwrap();
27020        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27021        let catalog = active_registry().unwrap();
27022        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27023        let shared = NodeShared::new(
27024            handle,
27025            "fixture-token".to_owned(),
27026            NodeId::new("node-1").unwrap(),
27027            vec![workspace],
27028            vec![agent("claude")],
27029        );
27030        let request = NodeRequest::BrowseHostDirectories {
27031            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27032            after: None,
27033        };
27034        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27035            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27036        }));
27037        assert!(!request_uses_unnegotiated_capability(
27038            &request,
27039            &baseline_capabilities().unwrap(),
27040        ));
27041        assert!(request_uses_unnegotiated_capability(&request, &[]));
27042
27043        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27044            .await
27045            .unwrap();
27046        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27047            panic!("host directory browse returned another response");
27048        };
27049        assert_eq!(
27050            listing
27051                .entries
27052                .iter()
27053                .map(|entry| entry.display_name.as_str())
27054                .collect::<Vec<_>>(),
27055            vec!["directory-a", "directory-b"],
27056        );
27057        assert!(listing.entries.iter().all(|entry| !entry.is_link));
27058        assert!(!listing.incomplete);
27059        std::fs::remove_dir_all(root).unwrap();
27060    }
27061
27062    #[tokio::test]
27063    async fn observer_can_inspect_only_a_registered_workspace() {
27064        let root = temporary_workspace_root("observer");
27065        std::fs::create_dir_all(root.join("src")).unwrap();
27066        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27067        let workspace_id = WorkspaceId::new("primary").unwrap();
27068        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27069        let catalog = active_registry().unwrap();
27070        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27071        let shared = NodeShared::new(
27072            handle,
27073            "fixture-token".to_owned(),
27074            NodeId::new("node-1").unwrap(),
27075            vec![workspace],
27076            vec![agent("claude")],
27077        );
27078
27079        let response = process_request_inner(
27080            &shared,
27081            77,
27082            ClientRole::Observer,
27083            NodeRequest::InspectWorkspace {
27084                workspace_id: workspace_id.clone(),
27085            },
27086        )
27087        .await
27088        .unwrap();
27089        let NodeResponse::WorkspaceInspected { inspection } = response else {
27090            panic!("workspace inspection returned another response");
27091        };
27092        assert_eq!(inspection.workspace_id, workspace_id);
27093        assert!(inspection.entries.iter().any(|entry| {
27094            entry.relative_path.as_utf8() == Some("src/main.rs")
27095        }));
27096
27097        let unknown = process_request_inner(
27098            &shared,
27099            77,
27100            ClientRole::Observer,
27101            NodeRequest::InspectWorkspace {
27102                workspace_id: WorkspaceId::new("unknown").unwrap(),
27103            },
27104        )
27105        .await
27106        .unwrap_err();
27107        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27108        std::fs::remove_dir_all(&root).unwrap();
27109    }
27110
27111    #[test]
27112    fn context_pack_source_accepts_only_running_or_successful_exit() {
27113        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27114        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27115            exit_code: Some(0),
27116        }));
27117        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27118            exit_code: Some(1),
27119        }));
27120        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27121            exit_code: None,
27122        }));
27123        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27124            message: "provider failed".to_owned(),
27125        }));
27126        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27127        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27128        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27129    }
27130
27131    #[test]
27132    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27133        let git = git_snapshot_for_parser();
27134        let first = ContextPackRepositoryObservation {
27135            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27136            git: git.clone(),
27137            files: Vec::new(),
27138        };
27139        let second = ContextPackRepositoryObservation {
27140            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27141            git,
27142            files: Vec::new(),
27143        };
27144
27145        let error = stable_context_pack_repository(first, second).unwrap_err();
27146
27147        assert_eq!(
27148            error.code,
27149            NodeFailureCode::ContextPackMaterializationFailed,
27150        );
27151    }
27152
27153    #[test]
27154    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27155        let git = git_snapshot_for_parser();
27156        let first = ContextPackRepositoryObservation {
27157            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27158            git: git.clone(),
27159            files: vec![ContextPackRepositoryFileSource::utf8(
27160                "README.md",
27161                "first\n".to_owned(),
27162                6,
27163            )],
27164        };
27165        let second = ContextPackRepositoryObservation {
27166            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27167            git,
27168            files: vec![ContextPackRepositoryFileSource::utf8(
27169                "README.md",
27170                "other\n".to_owned(),
27171                6,
27172            )],
27173        };
27174
27175        let error = stable_context_pack_repository(first, second).unwrap_err();
27176
27177        assert_eq!(
27178            error.code,
27179            NodeFailureCode::ContextPackMaterializationFailed,
27180        );
27181    }
27182
27183    #[tokio::test]
27184    async fn native_session_catalog_zero_active_session_fixture() {
27185        let workspace_root = temporary_workspace_root("native-catalog-workspace");
27186        let history_root = temporary_workspace_root("native-catalog-history");
27187        let outside_root = temporary_workspace_root("native-catalog-outside");
27188        std::fs::create_dir_all(&workspace_root).unwrap();
27189        std::fs::create_dir_all(&history_root).unwrap();
27190        std::fs::create_dir_all(&outside_root).unwrap();
27191        let transcript = |session_id: &str, cwd: &Path, title: &str| {
27192            [
27193                serde_json::json!({
27194                    "type": "user",
27195                    "sessionId": session_id,
27196                    "cwd": cwd.to_str().unwrap(),
27197                    "message": { "content": "raw user text must not cross the catalog wire" }
27198                }),
27199                serde_json::json!({
27200                    "type": "assistant",
27201                    "sessionId": session_id,
27202                    "cwd": cwd.to_str().unwrap(),
27203                    "message": { "model": "claude-sonnet", "content": "raw answer" }
27204                }),
27205                serde_json::json!({
27206                    "type": "ai-title",
27207                    "sessionId": session_id,
27208                    "aiTitle": title
27209                }),
27210            ]
27211            .into_iter()
27212            .map(|record| record.to_string())
27213            .collect::<Vec<_>>()
27214            .join("\n")
27215        };
27216        std::fs::write(
27217            history_root.join("stable-session.jsonl"),
27218            transcript("stable-session", &workspace_root, "Workspace review"),
27219        )
27220        .unwrap();
27221        std::fs::write(
27222            history_root.join("outside-session.jsonl"),
27223            transcript("outside-session", &outside_root, "Outside review"),
27224        )
27225        .unwrap();
27226        let history = NativeHistoryConfig::new(vec![
27227            NativeHistoryRoot::new(
27228                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27229                HistorySourceLayout::SingleNdjson,
27230                history_root.clone(),
27231            )
27232            .unwrap(),
27233        ])
27234        .unwrap();
27235        let catalog = active_registry().unwrap();
27236        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27237        assert!(handle.snapshot().sessions.is_empty());
27238        let workspace_id = WorkspaceId::new("primary").unwrap();
27239        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27240        let mut shared = NodeShared::new(
27241            handle,
27242            "fixture-token".to_owned(),
27243            NodeId::new("node-native-catalog").unwrap(),
27244            vec![workspace],
27245            vec![agent("claude")],
27246        );
27247        shared.native_session_catalog = Some(Arc::new(Mutex::new(
27248            NativeSessionCatalogAuthority::new(history),
27249        )));
27250        let route = NativeSessionCatalogRoute::workspace(
27251            workspace_id.clone(),
27252            agent("claude"),
27253        );
27254        let request = NodeRequest::CatalogNativeSessions {
27255            route: route.clone(),
27256            limit: 2,
27257        };
27258        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27259            .await
27260            .unwrap_err();
27261        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27262        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27263            .await
27264            .unwrap();
27265        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27266            panic!("native catalog returned a different response");
27267        };
27268        let summary = summary.expect("node must emit native session catalog summary metadata");
27269        assert_eq!(entries.len(), 1);
27270        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27271        assert_eq!(entries[0].external_group, None);
27272        assert_eq!(entries[0].record_id, None);
27273        assert_eq!(entries[0].title, None);
27274        assert_eq!(entries[0].model, None);
27275        assert_eq!(entries[0].message_count, 0);
27276        let encoded_entries = serde_json::to_string(&entries).unwrap();
27277        assert!(!encoded_entries.contains("stable-session"));
27278        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27279        let page_request = NodeRequest::PageNativeSessions {
27280            route: route.clone(),
27281            window: crate::protocol::NativeSessionCatalogWindow::Recent,
27282            catalog_revision: summary.catalog_revision,
27283            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27284            after_selection_id: None,
27285            limit: 2,
27286        };
27287        let observer = process_request_inner(
27288            &shared,
27289            7,
27290            ClientRole::Observer,
27291            page_request.clone(),
27292        )
27293        .await
27294        .unwrap_err();
27295        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27296        let page_response = process_request_inner(
27297            &shared,
27298            7,
27299            ClientRole::Operator,
27300            page_request.clone(),
27301        )
27302        .await
27303        .unwrap();
27304        assert!(matches!(
27305            page_response,
27306            NodeResponse::NativeSessionsPaged { page, .. }
27307                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27308        ));
27309        let stale = process_request_inner(
27310            &shared,
27311            7,
27312            ClientRole::Operator,
27313            NodeRequest::PageNativeSessions {
27314                route: route.clone(),
27315                window: crate::protocol::NativeSessionCatalogWindow::Recent,
27316                catalog_revision: summary.catalog_revision.wrapping_add(1),
27317                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27318                after_selection_id: None,
27319                limit: 2,
27320            },
27321        )
27322        .await
27323        .unwrap_err();
27324        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27325        let selection = NativeSessionSelection {
27326            route: route.clone(),
27327            catalog_revision: summary.catalog_revision,
27328            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27329            selection_id: entries[0].selection_id.clone(),
27330        };
27331        let preview_response = process_request_inner(
27332            &shared,
27333            7,
27334            ClientRole::Operator,
27335            NodeRequest::PreviewNativeSession {
27336                selection: selection.clone(),
27337                message_limit: 1,
27338            },
27339        )
27340        .await
27341        .unwrap();
27342        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27343            panic!("native preview returned a different response");
27344        };
27345        assert_eq!(preview.messages.len(), 1);
27346        assert!(preview.message_count_exact);
27347        assert_eq!(preview.messages[0].text, "raw answer");
27348        assert!(preview.truncated);
27349
27350        let index_request = NodeRequest::IndexNativeSession {
27351            selection: selection.clone(),
27352            display_name: "Saved Claude".to_owned(),
27353        };
27354        let observer = process_request_inner(
27355            &shared,
27356            7,
27357            ClientRole::Observer,
27358            index_request.clone(),
27359        )
27360        .await
27361        .unwrap_err();
27362        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27363        let without_controller = process_request_inner(
27364            &shared,
27365            7,
27366            ClientRole::Operator,
27367            index_request.clone(),
27368        )
27369        .await
27370        .unwrap_err();
27371        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27372        let acquired = process_request_inner(
27373            &shared,
27374            7,
27375            ClientRole::Operator,
27376            NodeRequest::AcquireController { lease_ms: 5_000 },
27377        )
27378        .await
27379        .unwrap();
27380        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27381        let indexed = process_request_inner(
27382            &shared,
27383            7,
27384            ClientRole::Operator,
27385            index_request,
27386        )
27387        .await
27388        .unwrap();
27389        assert!(indexed.native_session_index_contract_is_valid());
27390        let NodeResponse::NativeSessionIndexed {
27391            selection: echoed_selection,
27392            record,
27393        } = indexed else {
27394            panic!("native session index returned a different response");
27395        };
27396        assert_eq!(echoed_selection, selection);
27397        let recataloged = process_request_inner(
27398            &shared,
27399            7,
27400            ClientRole::Operator,
27401            NodeRequest::CatalogNativeSessions {
27402                route,
27403                limit: 2,
27404            },
27405        )
27406        .await
27407        .unwrap();
27408        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27409            panic!("native catalog returned a different response after indexing");
27410        };
27411        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27412
27413        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27414        let external_catalog = process_request_inner(
27415            &shared,
27416            7,
27417            ClientRole::Operator,
27418            NodeRequest::CatalogNativeSessions {
27419                route: unregistered_route.clone(),
27420                limit: 2,
27421            },
27422        )
27423        .await
27424        .unwrap();
27425        let external_json = serde_json::to_string(&external_catalog).unwrap();
27426        assert!(!external_json.contains("outside-session"));
27427        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27428        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27429        assert!(!external_json.contains("\"session_id\""));
27430        assert!(!external_json.contains("\"cwd\""));
27431        assert!(!external_json.contains("\"path\""));
27432        let NodeResponse::NativeSessionsCataloged {
27433            route: echoed_route,
27434            entries: external_entries,
27435            summary: Some(external_summary),
27436        } = external_catalog
27437        else {
27438            panic!("unregistered native catalog returned a different response");
27439        };
27440        assert_eq!(echoed_route, unregistered_route);
27441        assert_eq!(external_entries.len(), 1);
27442        assert_eq!(external_entries[0].record_id, None);
27443        let external_group = external_entries[0]
27444            .external_group
27445            .as_ref()
27446            .expect("unregistered session must have an external group");
27447        assert_eq!(
27448            external_group.kind,
27449            gate4agent_types::NativeSessionExternalGroupKind::Project,
27450        );
27451        assert!(external_group.group_id.starts_with("external-"));
27452        assert_eq!(
27453            external_group.display_name,
27454            outside_root.file_name().unwrap().to_string_lossy(),
27455        );
27456        let external_selection = NativeSessionSelection {
27457            route: unregistered_route,
27458            catalog_revision: external_summary.catalog_revision,
27459            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27460            selection_id: external_entries[0].selection_id.clone(),
27461        };
27462        let external_preview = process_request_inner(
27463            &shared,
27464            7,
27465            ClientRole::Operator,
27466            NodeRequest::PreviewNativeSession {
27467                selection: external_selection.clone(),
27468                message_limit: 1,
27469            },
27470        )
27471        .await
27472        .unwrap();
27473        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27474        assert!(!external_preview_json.contains("outside-session"));
27475        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27476        assert!(!external_preview_json.contains("\"session_id\""));
27477        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27478            panic!("unregistered native preview returned a different response");
27479        };
27480        assert_eq!(preview.messages.len(), 1);
27481        let external_index = process_request_inner(
27482            &shared,
27483            7,
27484            ClientRole::Operator,
27485            NodeRequest::IndexNativeSession {
27486                selection: external_selection,
27487                display_name: "External Claude".to_owned(),
27488            },
27489        )
27490        .await
27491        .unwrap_err();
27492        assert_eq!(
27493            external_index.code,
27494            NodeFailureCode::WorkspaceRegistrationRequired,
27495        );
27496        let record_response = process_request_inner(
27497            &shared,
27498            7,
27499            ClientRole::Operator,
27500            NodeRequest::PreviewSessionRecord {
27501                record_id: record.record_id.clone(),
27502                message_limit: 2,
27503            },
27504        )
27505        .await
27506        .unwrap();
27507        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27508            panic!("record preview returned a different response");
27509        };
27510        assert_eq!(record_id, record.record_id);
27511        assert_eq!(preview.messages.len(), 2);
27512        let encoded = serde_json::to_string(&preview).unwrap();
27513        assert!(!encoded.contains("stable-session"));
27514        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27515        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27516            panic!("record preview history summary returned a different response");
27517        };
27518        let summaries = events
27519            .iter()
27520            .filter_map(|envelope| match &envelope.event {
27521                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27522                    if record_id == &record.record_id =>
27523                {
27524                    Some(*summary)
27525                }
27526                _ => None,
27527            })
27528            .collect::<Vec<_>>();
27529        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27530        let summary_json = serde_json::to_string(&summaries).unwrap();
27531        for private in [
27532            "stable-session",
27533            "raw user text must not cross the catalog wire",
27534            "raw answer",
27535            "Workspace review",
27536            "claude-sonnet",
27537        ] {
27538            assert!(!summary_json.contains(private), "{private}");
27539        }
27540        let identity = record.provider_session.clone().unwrap();
27541        assert_eq!(
27542            shared.revalidate_session_record_preview(&record, &identity),
27543            Ok(())
27544        );
27545        shared.remove_record_memory(&record.record_id);
27546        assert_eq!(
27547            shared
27548                .revalidate_session_record_preview(&record, &identity)
27549                .unwrap_err()
27550                .code,
27551            NodeFailureCode::UnknownSessionRecord
27552        );
27553        let released = process_request_inner(
27554            &shared,
27555            7,
27556            ClientRole::Operator,
27557            NodeRequest::ReleaseController,
27558        )
27559        .await
27560        .unwrap();
27561        assert!(matches!(released, NodeResponse::Controller { controller: None }));
27562        assert!(shared.controller_state().is_none());
27563        std::fs::remove_dir_all(workspace_root).unwrap();
27564        std::fs::remove_dir_all(history_root).unwrap();
27565        std::fs::remove_dir_all(outside_root).unwrap();
27566    }
27567
27568    #[tokio::test]
27569    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27570        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27571        std::fs::create_dir_all(&history_root).unwrap();
27572        let history = NativeHistoryConfig::new(vec![
27573            NativeHistoryRoot::new(
27574                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27575                HistorySourceLayout::SingleNdjson,
27576                history_root.clone(),
27577            )
27578            .unwrap(),
27579        ])
27580        .unwrap();
27581        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27582        let held = catalog.lock().unwrap();
27583        let result = timeout(
27584            Duration::from_secs(1),
27585            run_native_session_catalog_operation(
27586                Arc::clone(&catalog),
27587                "native session catalog",
27588                |_| Ok::<(), std::convert::Infallible>(()),
27589            ),
27590        )
27591        .await
27592        .expect("concurrent native catalog request must not wait for the operation deadline")
27593        .unwrap_err();
27594        assert_eq!(result.code, NodeFailureCode::BackendBusy);
27595        drop(held);
27596        std::fs::remove_dir_all(history_root).unwrap();
27597    }
27598
27599    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27600        let unique = std::time::SystemTime::now()
27601            .duration_since(std::time::UNIX_EPOCH)
27602            .unwrap()
27603            .as_nanos();
27604        std::env::temp_dir().join(format!(
27605            "gate4agent-node-{label}-{}-{unique}",
27606            std::process::id(),
27607        ))
27608    }
27609
27610    fn git_snapshot_for_parser() -> GitSnapshot {
27611        GitSnapshot {
27612            is_repository: true,
27613            branch: Some("main".to_owned()),
27614            status: Vec::new(),
27615            recent_commits: Vec::new(),
27616            worktrees: Vec::new(),
27617            managed_worktree: None,
27618            truncated: false,
27619            diagnostic: None,
27620        }
27621    }
27622
27623    #[tokio::test]
27624    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27625        let root = temporary_workspace_root("managed-git-scope");
27626        std::fs::create_dir_all(&root).unwrap();
27627        run_test_git(&root, &["init"]);
27628        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27629        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27630        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27631        run_test_git(&root, &["add", "seed.txt"]);
27632        run_test_git(&root, &["commit", "-m", "seed"]);
27633        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27634
27635        let workspace_id = WorkspaceId::new("managed-a").unwrap();
27636        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27637        let catalog = active_registry().unwrap();
27638        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27639        let shared = NodeShared::new(
27640            handle,
27641            "fixture-token".to_owned(),
27642            NodeId::new("node-1").unwrap(),
27643            vec![workspace],
27644            vec![agent("claude")],
27645        );
27646        let mut lease = ManagedWorktreeLeaseRecord {
27647            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27648            source_workspace_id: WorkspaceId::new("primary").unwrap(),
27649            workspace_id: workspace_id.clone(),
27650            profile_id: WorktreeProfileId::new("default").unwrap(),
27651            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27652            target_root: root.to_string_lossy().into_owned(),
27653            branch: "gate4agent/a".to_owned(),
27654            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27655            expected_head: None,
27656            retention: ManagedWorktreeRetention::Retain,
27657            state: ManagedWorktreeLeaseState::InUse,
27658            session_holders: vec![ManagedWorktreeSessionHolder {
27659                incarnation_id: shared.incarnation_id,
27660                instance_id: AgentInstanceId(7),
27661                generation: SessionGeneration(1),
27662            }],
27663            record_holders: Vec::new(),
27664            cleanup_failure: None,
27665            created_at_unix_ms: 1,
27666            updated_at_unix_ms: 1,
27667        };
27668        *shared
27669            .managed_worktrees
27670            .lock()
27671            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27672            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27673
27674        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27675        let scope = inspection.git.managed_worktree.unwrap();
27676        assert_eq!(scope.lease_id, lease.lease_id);
27677        assert_eq!(scope.branch, "gate4agent/a");
27678        assert_eq!(scope.active_session_count, 1);
27679
27680        lease.branch = "gate4agent/b".to_owned();
27681        *shared
27682            .managed_worktrees
27683            .lock()
27684            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27685            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27686        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27687        assert!(mismatch.git.managed_worktree.is_none());
27688        std::fs::remove_dir_all(root).unwrap();
27689    }
27690
27691    #[tokio::test]
27692    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27693        let root = temporary_workspace_root("workspace-file-write-controller");
27694        std::fs::create_dir_all(root.join("src")).unwrap();
27695        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27696        let workspace_id = WorkspaceId::new("primary").unwrap();
27697        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27698        let catalog = active_registry().unwrap();
27699        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27700        let shared = NodeShared::new(
27701            handle,
27702            "fixture-token".to_owned(),
27703            NodeId::new("node-1").unwrap(),
27704            vec![workspace],
27705            vec![agent("claude")],
27706        );
27707        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27708        let expected_revision = workspace_file_revision(b"old\n");
27709        let request = NodeRequest::WriteWorkspaceFile {
27710            workspace_id: workspace_id.clone(),
27711            path: path.clone(),
27712            expected_revision: expected_revision.clone(),
27713            text: "new\n".to_owned(),
27714        };
27715        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27716            .await
27717            .unwrap_err();
27718        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27719        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27720            .await
27721            .unwrap_err();
27722        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27723        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27724        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27725        let NodeResponse::WorkspaceFileWritten { file } = response else {
27726            panic!("workspace file write returned another response");
27727        };
27728        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27729        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27730        let stale = process_request_inner(
27731            &shared,
27732            77,
27733            ClientRole::Operator,
27734            NodeRequest::WriteWorkspaceFile {
27735                workspace_id,
27736                path,
27737                expected_revision,
27738                text: "lost update\n".to_owned(),
27739            },
27740        )
27741        .await
27742        .unwrap_err();
27743        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27744        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27745        std::fs::remove_dir_all(root).unwrap();
27746    }
27747
27748    #[tokio::test]
27749    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27750        let root = temporary_workspace_root("workspace-entry-create-controller");
27751        std::fs::create_dir_all(root.join("src")).unwrap();
27752        let workspace_id = WorkspaceId::new("primary").unwrap();
27753        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27754        let catalog = active_registry().unwrap();
27755        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27756        let shared = NodeShared::new(
27757            handle,
27758            "fixture-token".to_owned(),
27759            NodeId::new("node-1").unwrap(),
27760            vec![workspace],
27761            vec![agent("claude")],
27762        );
27763        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27764        let request = NodeRequest::CreateWorkspaceFile {
27765            workspace_id: workspace_id.clone(),
27766            path: path.clone(),
27767        };
27768        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27769            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27770        }));
27771        assert!(!request_uses_unnegotiated_capability(
27772            &request,
27773            &baseline_capabilities().unwrap(),
27774        ));
27775        assert!(request_uses_unnegotiated_capability(&request, &[]));
27776        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27777            .await
27778            .unwrap_err();
27779        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27780        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27781            .await
27782            .unwrap_err();
27783        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27784        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27785
27786        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27787            .await
27788            .unwrap();
27789        let NodeResponse::WorkspaceFileCreated { file } = response else {
27790            panic!("workspace file create returned another response");
27791        };
27792        assert_eq!(file.workspace_id, workspace_id);
27793        assert_eq!(file.path, path);
27794        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27795        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27796
27797        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27798            .await
27799            .unwrap_err();
27800        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27801
27802        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27803        let response = process_request_inner(
27804            &shared,
27805            77,
27806            ClientRole::Operator,
27807            NodeRequest::CreateWorkspaceDirectory {
27808                workspace_id: workspace_id.clone(),
27809                path: directory_path.clone(),
27810            },
27811        )
27812        .await
27813        .unwrap();
27814        assert_eq!(
27815            response,
27816            NodeResponse::WorkspaceDirectoryCreated {
27817                workspace_id: workspace_id.clone(),
27818                entry: WorkspaceEntry {
27819                    relative_path: directory_path,
27820                    kind: WorkspaceEntryKind::Directory,
27821                },
27822            },
27823        );
27824        assert!(root.join("src/nested").is_dir());
27825
27826        let missing_parent = process_request_inner(
27827            &shared,
27828            77,
27829            ClientRole::Operator,
27830            NodeRequest::CreateWorkspaceFile {
27831                workspace_id: workspace_id.clone(),
27832                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27833            },
27834        )
27835        .await
27836        .unwrap_err();
27837        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27838        let non_directory_parent = process_request_inner(
27839            &shared,
27840            77,
27841            ClientRole::Operator,
27842            NodeRequest::CreateWorkspaceFile {
27843                workspace_id,
27844                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27845            },
27846        )
27847        .await
27848        .unwrap_err();
27849        assert_eq!(
27850            non_directory_parent.code,
27851            NodeFailureCode::RepositoryParentNotDirectory,
27852        );
27853        std::fs::remove_dir_all(root).unwrap();
27854    }
27855
27856    #[tokio::test]
27857    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27858        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27859        std::fs::create_dir_all(root.join("src")).unwrap();
27860        #[cfg(windows)]
27861        let root = std::fs::canonicalize(root).unwrap();
27862        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27863        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27864        let barrier = Arc::new(std::sync::Barrier::new(2));
27865        let done = Arc::new(AtomicBool::new(false));
27866        let task_commit_state = Arc::clone(&commit_state);
27867        let task_barrier = Arc::clone(&barrier);
27868        let task_done = Arc::clone(&done);
27869        let task_root = root.clone();
27870        let task = tokio::task::spawn_blocking(move || {
27871            task_barrier.wait();
27872            let result = create_workspace_file_on_disk(
27873                &task_root,
27874                &path,
27875                &task_commit_state,
27876            );
27877            task_done.store(true, Ordering::Release);
27878            result
27879        });
27880
27881        let error = settle_workspace_entry_create(
27882            task,
27883            Arc::clone(&commit_state),
27884            Duration::from_millis(10),
27885            "fixture deadline",
27886            "fixture task failed",
27887        )
27888        .await
27889        .unwrap_err();
27890        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27891        assert_eq!(
27892            commit_state.load(Ordering::Acquire),
27893            WORKSPACE_ENTRY_CREATE_CANCELED,
27894        );
27895
27896        barrier.wait();
27897        timeout(Duration::from_secs(1), async {
27898            while !done.load(Ordering::Acquire) {
27899                tokio::task::yield_now().await;
27900            }
27901        })
27902        .await
27903        .expect("canceled blocking create did not settle");
27904        assert!(!root.join("src/late.rs").exists());
27905        std::fs::remove_dir_all(root).unwrap();
27906    }
27907}
27908
27909#[cfg(test)]
27910mod provisional_spawn_tests {
27911    use super::*;
27912    use std::sync::Arc;
27913
27914    fn shared() -> NodeShared {
27915        let catalog = active_registry().unwrap();
27916        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27917        let workspace = WorkspaceConfig::new(
27918            WorkspaceId::new("primary").unwrap(),
27919            std::env::current_dir().unwrap(),
27920        )
27921        .unwrap()
27922        .with_worktree_service_mode(WorktreeServiceMode::Manual);
27923        NodeShared::new(
27924            handle,
27925            "fixture-token".to_owned(),
27926            NodeId::new("node-provisional-test").unwrap(),
27927            vec![workspace],
27928            vec![AgentId::new("claude").unwrap()],
27929        )
27930    }
27931
27932    /// Provisional draft map: sessionize by instance id only; 50 sequential
27933    /// mark/claim cycles never collide, and a claimed draft is never reaped.
27934    #[test]
27935    fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
27936        let shared = shared();
27937        let ws = WorkspaceId::new("primary").unwrap();
27938        let mut ids = Vec::with_capacity(50);
27939        for i in 1u64..=50 {
27940            let id = AgentInstanceId(i);
27941            ids.push(id);
27942            let address = SessionAddress {
27943                workspace_id: ws.clone(),
27944                session: SessionKey {
27945                    instance_id: id,
27946                    generation: SessionGeneration(1),
27947                },
27948            };
27949            shared.mark_spawn_provisional(address);
27950        }
27951        assert_eq!(
27952            shared
27953                .provisional_spawns
27954                .lock()
27955                .unwrap_or_else(|p| p.into_inner())
27956                .len(),
27957            50
27958        );
27959        for id in ids.iter().step_by(2) {
27960            shared.sessionize_spawn(*id);
27961        }
27962        assert_eq!(
27963            shared
27964                .provisional_spawns
27965                .lock()
27966                .unwrap_or_else(|p| p.into_inner())
27967                .len(),
27968            25
27969        );
27970        assert!(shared.take_due_provisional_spawns().is_empty());
27971        {
27972            let mut map = shared
27973                .provisional_spawns
27974                .lock()
27975                .unwrap_or_else(|p| p.into_inner());
27976            let past = Instant::now() - Duration::from_secs(30);
27977            for (_, (_, at)) in map.iter_mut() {
27978                *at = past;
27979            }
27980        }
27981        let due = shared.take_due_provisional_spawns();
27982        assert_eq!(due.len(), 25);
27983        let due_ids: std::collections::BTreeSet<_> =
27984            due.iter().map(|a| a.session.instance_id.0).collect();
27985        let expected: std::collections::BTreeSet<_> =
27986            ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
27987        assert_eq!(due_ids, expected);
27988        assert!(shared
27989            .provisional_spawns
27990            .lock()
27991            .unwrap_or_else(|p| p.into_inner())
27992            .is_empty());
27993    }
27994
27995    /// Twenty parallel provisional marks must each get their own instance key;
27996    /// killing/sessionizing one id must not touch the others.
27997    #[test]
27998    fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
27999        let shared = Arc::new(shared());
28000        let ws = WorkspaceId::new("primary").unwrap();
28001        let mut handles = Vec::new();
28002        for i in 1u64..=20 {
28003            let shared = Arc::clone(&shared);
28004            let ws = ws.clone();
28005            handles.push(std::thread::spawn(move || {
28006                let id = AgentInstanceId(i);
28007                let address = SessionAddress {
28008                    workspace_id: ws,
28009                    session: SessionKey {
28010                        instance_id: id,
28011                        generation: SessionGeneration(1),
28012                    },
28013                };
28014                shared.mark_spawn_provisional(address);
28015                id
28016            }));
28017        }
28018        let mut ids = Vec::new();
28019        for h in handles {
28020            ids.push(h.join().unwrap());
28021        }
28022        ids.sort_by_key(|id| id.0);
28023        assert_eq!(ids.len(), 20);
28024        ids.dedup();
28025        assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28026        assert_eq!(
28027            shared
28028                .provisional_spawns
28029                .lock()
28030                .unwrap_or_else(|p| p.into_inner())
28031                .len(),
28032            20
28033        );
28034        shared.sessionize_spawn(AgentInstanceId(7));
28035        let left: Vec<_> = shared
28036            .provisional_spawns
28037            .lock()
28038            .unwrap_or_else(|p| p.into_inner())
28039            .keys()
28040            .map(|id| id.0)
28041            .collect();
28042        assert!(!left.contains(&7));
28043        assert_eq!(left.len(), 19);
28044    }
28045}
28046
28047
28048#[cfg(unix)]
28049extern "C" {
28050    #[link_name = "geteuid"]
28051    fn libc_geteuid() -> u32;
28052}
28053
28054#[cfg(test)]
28055mod call_home_link_tests {
28056    use super::*;
28057
28058    #[test]
28059    fn call_home_backoff_is_capped_and_monotonic() {
28060        let mut last = Duration::ZERO;
28061        for attempt in 1..20 {
28062            let next = call_home_backoff(attempt);
28063            assert!(next >= last);
28064            assert!(next <= Duration::from_secs(8));
28065            last = next;
28066        }
28067        assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28068    }
28069
28070    #[tokio::test]
28071    async fn call_home_socket_gets_keepalive() {
28072        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28073        let addr = listener.local_addr().unwrap();
28074        let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28075        let client = client.unwrap();
28076        enable_call_home_keepalive(&client).unwrap();
28077        let sock = socket2::SockRef::from(&client);
28078        assert!(sock.keepalive().unwrap());
28079        #[cfg(any(target_os = "linux", target_os = "macos"))]
28080        assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28081    }
28082}